From a6ab26d149da17db6dedd316b47a4afd5ff51009 Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Wed, 4 Dec 2024 18:05:32 +0100 Subject: [PATCH] EPA-171: Removed JSON content-type for /auth and improved error messages --- .../relyingparty/ws/AuthEndpoint.java | 28 --------- .../ws/ThrowableExceptionMapper.java | 58 ++++++++----------- .../src/main/resources/i18n_de_DE.properties | 23 ++++---- .../src/main/resources/i18n_en_US.properties | 23 ++++---- .../relyingparty/ws/AuthEndpointTest.java | 40 ------------- .../oviva/ehealthid/auth/AuthException.java | 30 ++++++++++ .../oviva/ehealthid/auth/AuthExceptions.java | 45 +++++++++----- .../steps/SelectSectoralIdpStepImpl.java | 9 ++- 8 files changed, 115 insertions(+), 141 deletions(-) create mode 100644 ehealthid/src/main/java/com/oviva/ehealthid/auth/AuthException.java diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/AuthEndpoint.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/AuthEndpoint.java index 8b43936..8f2119b 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/AuthEndpoint.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/AuthEndpoint.java @@ -69,34 +69,6 @@ public Response auth( .build(); } - @GET - @Produces(MediaType.APPLICATION_JSON) - public Response authJson( - @QueryParam("scope") String scope, - @QueryParam("state") String state, - @QueryParam("response_type") String responseType, - @QueryParam("client_id") String clientId, - @QueryParam("redirect_uri") String redirectUri, - @QueryParam("nonce") String nonce) { - - var uri = mustParse(redirectUri); - - var res = - authService.auth( - new AuthorizationRequest(scope, state, responseType, clientId, uri, nonce)); - - var availableIdentityProviders = - res.identityProviders().stream() - .map(idp -> new IdpEntry(idp.iss(), idp.name(), idp.logoUrl())) - .toList(); - - var body = new AuthResponse(availableIdentityProviders); - - return Response.ok(body, MediaType.APPLICATION_JSON_TYPE) - .cookie(createSessionCookie(res.sessionId())) - .build(); - } - @NonNull private URI mustParse(@Nullable String uri) { if (uri == null || uri.isBlank()) { diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java index 8bef48c..5c8100b 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java @@ -4,6 +4,7 @@ import static com.oviva.ehealthid.relyingparty.util.LocaleUtils.getNegotiatedLocale; import com.fasterxml.jackson.annotation.JsonProperty; +import com.oviva.ehealthid.auth.AuthException; import com.oviva.ehealthid.fedclient.FederationException; import com.oviva.ehealthid.relyingparty.svc.AuthenticationException; import com.oviva.ehealthid.relyingparty.svc.ValidationException; @@ -33,15 +34,13 @@ public class ThrowableExceptionMapper implements ExceptionMapper { private static final String SERVER_ERROR_MESSAGE = "error.serverError"; private static final String FEDERATION_ERROR_MESSAGE = "error.federationError"; + private static final String AUTH_ERROR_MESSAGE = "error.authError"; private final Pages pages = new Pages(new TemplateRenderer()); @Context UriInfo uriInfo; @Context Request request; @Context HttpHeaders headers; - // Note: below fields MUST be non-final for mocking - private MediaTypeNegotiator mediaTypeNegotiator = new ResteasyMediaTypeNegotiator(); - private Logger logger = LoggerFactory.getLogger(ThrowableExceptionMapper.class); @Override @@ -66,7 +65,7 @@ public Response toResponse(Throwable exception) { return Response.seeOther(ve.seeOther()).build(); } - return buildContentNegotiatedErrorResponse(ve.localizedMessage(), Status.BAD_REQUEST); + return buildErrorResponse(ve.localizedMessage(), Status.BAD_REQUEST); } // the remaining exceptions are unexpected, let's log them @@ -74,47 +73,38 @@ public Response toResponse(Throwable exception) { if (exception instanceof FederationException fe) { var errorMessage = new Message(FEDERATION_ERROR_MESSAGE, fe.reason().name()); - return buildContentNegotiatedErrorResponse(errorMessage, Status.INTERNAL_SERVER_ERROR); + return buildErrorResponse(errorMessage, Status.INTERNAL_SERVER_ERROR); + } + + if (exception instanceof AuthException ae) { + var errorMessage = new Message(AUTH_ERROR_MESSAGE, ae.reason().name()); + return buildErrorResponse(errorMessage, Status.INTERNAL_SERVER_ERROR); } var status = Status.INTERNAL_SERVER_ERROR; var errorMessage = new Message(SERVER_ERROR_MESSAGE, (String) null); - return buildContentNegotiatedErrorResponse(errorMessage, status); + return buildErrorResponse(errorMessage, status); } - private Response buildContentNegotiatedErrorResponse(Message message, StatusType status) { + private Response buildErrorResponse(Message message, StatusType status) { var headerString = headers.getHeaderString("Accept-Language"); var locale = getNegotiatedLocale(headerString); - var mediaType = - mediaTypeNegotiator.bestMatch( - headers.getAcceptableMediaTypes(), - List.of(MediaType.TEXT_HTML_TYPE, MediaType.APPLICATION_JSON_TYPE)); - - if (MediaType.TEXT_HTML_TYPE.equals(mediaType)) { - var body = pages.error(message, locale); - - // FIXES oviva-ag/ehealthid-relying-party #58 / EPA-102 - // resteasy has a built-in `MessageSanitizerContainerResponseFilter` escaping all non status - // 200 - // 'text/html' responses - // if the entity is a string. - // The corresponding "resteasy.disable.html.sanitizer" config does not work with SeBootstrap - // currently (resteasy 6.2). - return Response.status(status) - .entity(body.getBytes(StandardCharsets.UTF_8)) - .type(MediaType.TEXT_HTML_TYPE) - .build(); - } - - if (MediaType.APPLICATION_JSON_TYPE.equals(mediaType)) { - var body = new Problem("/server_error", message.messageKey()); - return Response.status(status).entity(body).type(MediaType.APPLICATION_JSON_TYPE).build(); - } - - return Response.status(status).build(); + var body = pages.error(message, locale); + + // FIXES oviva-ag/ehealthid-relying-party #58 / EPA-102 + // resteasy has a built-in `MessageSanitizerContainerResponseFilter` escaping all non status + // 200 + // 'text/html' responses + // if the entity is a string. + // The corresponding "resteasy.disable.html.sanitizer" config does not work with SeBootstrap + // currently (resteasy 6.2). + return Response.status(status) + .entity(body.getBytes(StandardCharsets.UTF_8)) + .type(MediaType.TEXT_HTML_TYPE) + .build(); } private void debugLog(Throwable exception) { diff --git a/ehealthid-rp/src/main/resources/i18n_de_DE.properties b/ehealthid-rp/src/main/resources/i18n_de_DE.properties index 46df774..fb694c8 100644 --- a/ehealthid-rp/src/main/resources/i18n_de_DE.properties +++ b/ehealthid-rp/src/main/resources/i18n_de_DE.properties @@ -1,18 +1,19 @@ lang=de-DE title=Anmeldung mit GesundheitsID -error.login=Einloggen mit GesundheitsID -error.serverError=Ohh nein! Unerwarteter Serverfehler. Bitte versuchen Sie es erneut. -error.federationError=Ohh nein! Unerwarteter Fehler der GesundheitsID. Bitte versuchen Sie es erneut. Grund: %s -error.noProvider=Kein Identitätsanbieter ausgewählt. Bitte zurückgehen. -error.invalidSession=Oops, Sitzung unbekannt oder abgelaufen. Bitte starten Sie erneut. -error.insecureRedirect=Unsicherer redirect_uri='%s'. Falsch konfigurierter Server, bitte verwenden Sie 'https'. +error.authError=Ohh nein! Unerwarteter Fehler der GesundheitsID. Unterstützt ihre Krankenkasse bereits Apps? Grund: %s error.badRedirect=Ungültige redirect_uri='%s'. Übergebener Link ist nicht gültig. -error.untrustedRedirect=Nicht vertrauenswürdiger redirect_uri=%s. Falsch konfigurierter Server. -error.unsupportedScope=Scope '%s' wird nicht unterstützt -error.unsupportedResponseType=Nicht unterstützter Antworttyp: '%s' -error.blankUri=Leere Uri error.badUri=Falsch uri='%s' +error.blankUri=Leere Uri +error.federationError=Ohh nein! Unerwarteter Fehler der GesundheitsID. Bitte versuchen Sie es erneut. Grund: %s +error.insecureRedirect=Unsicherer redirect_uri='%s'. Falsch konfigurierter Server, bitte verwenden Sie 'https'. +error.invalidSession=Oops, Sitzung unbekannt oder abgelaufen. Bitte starten Sie erneut. +error.login=Einloggen mit GesundheitsID +error.noProvider=Kein Identitätsanbieter ausgewählt. Bitte zurückgehen. error.noRedirect=keine redirect_uri +error.serverError=Ohh nein! Unerwarteter Serverfehler. Bitte versuchen Sie es erneut. error.unparsableHeader=Fehlgeformter Accept-Language-Header-Wert kann nicht analysiert werden -idp.selection=Wählen Sie Ihren GesundheitsID Anbieter +error.unsupportedResponseType=Nicht unterstützter Antworttyp: '%s' +error.unsupportedScope=Scope '%s' wird nicht unterstützt +error.untrustedRedirect=Nicht vertrauenswürdiger redirect_uri=%s. Falsch konfigurierter Server. idp.login=Einloggen +idp.selection=Wählen Sie Ihren GesundheitsID Anbieter diff --git a/ehealthid-rp/src/main/resources/i18n_en_US.properties b/ehealthid-rp/src/main/resources/i18n_en_US.properties index e32fd78..b5c4250 100644 --- a/ehealthid-rp/src/main/resources/i18n_en_US.properties +++ b/ehealthid-rp/src/main/resources/i18n_en_US.properties @@ -1,18 +1,19 @@ lang=en-US title=Login with GesundheitsID -error.serverError=Ohh no! Unexpected server error. Please try again. -error.federationError=Ohh no! Unexpected eHealthID error. Cause: %s -error.noProvider =No identity provider selected. Please go back -error.invalidSession=Oops, session unknown or expired. Please start again. -error.insecureRedirect=Insecure redirect_uri='%s'. Misconfigured server, please use 'https'. +error.authError=Ohh no! Unexpected eHealthID error. Does your health insurance support apps? Cause: %s error.badRedirect=Bad redirect_uri='%s'. Passed link is not valid. -error.untrustedRedirect=Untrusted redirect_uri=%s. Misconfigured server. -error.blankUri=Blank uri -error.unsupportedScope=scope '%s' not supported -error.unsupportedResponseType=Unsupported response type: '%s' error.badUri=Bad uri='%s' +error.blankUri=Blank uri +error.federationError=Ohh no! Unexpected eHealthID error. Cause: %s +error.insecureRedirect=Insecure redirect_uri='%s'. Misconfigured server, please use 'https'. +error.invalidSession=Oops, session unknown or expired. Please start again. +error.login=Log in with GesundheitsID +error.noProvider=No identity provider selected. Please go back error.noRedirect=No redirect_uri +error.serverError=Ohh no! Unexpected server error. Please try again. error.unparsableHeader=Unable to parse malformed Accept-Language header value -error.login=Log in with GesundheitsID -idp.selection=Select your GesundheitsID Provider +error.unsupportedResponseType=Unsupported response type: '%s' +error.unsupportedScope=scope '%s' not supported +error.untrustedRedirect=Untrusted redirect_uri=%s. Misconfigured server. idp.login=Login +idp.selection=Select your GesundheitsID Provider diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/AuthEndpointTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/AuthEndpointTest.java index 23dc895..98277c9 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/AuthEndpointTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/AuthEndpointTest.java @@ -5,7 +5,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import com.oviva.ehealthid.fedclient.IdpEntry; import com.oviva.ehealthid.relyingparty.svc.AuthService; import com.oviva.ehealthid.relyingparty.svc.AuthService.AuthorizationRequest; import com.oviva.ehealthid.relyingparty.svc.AuthService.AuthorizationResponse; @@ -13,7 +12,6 @@ import com.oviva.ehealthid.relyingparty.svc.AuthService.SelectedIdpRequest; import com.oviva.ehealthid.relyingparty.svc.ValidationException; import com.oviva.ehealthid.relyingparty.util.IdGenerator; -import com.oviva.ehealthid.relyingparty.ws.AuthEndpoint.AuthResponse; import jakarta.ws.rs.core.Response.Status; import java.net.URI; import java.util.List; @@ -103,44 +101,6 @@ void auth_success() { } } - @Test - void authJson_success() { - var identityProviders = List.of(new IdpEntry("a", "A", null), new IdpEntry("b", "B", null)); - - var sessionId = IdGenerator.generateID(); - var authService = mock(AuthService.class); - when(authService.auth(any())) - .thenReturn(new AuthorizationResponse(identityProviders, sessionId)); - var sut = new AuthEndpoint(authService); - - var scope = "openid"; - var state = UUID.randomUUID().toString(); - var nonce = UUID.randomUUID().toString(); - var responseType = "code"; - var clientId = "myapp"; - - // when - try (var res = sut.authJson(scope, state, responseType, clientId, REDIRECT_URI, nonce)) { - - // then - assertEquals(Status.OK.getStatusCode(), res.getStatus()); - - var authResponse = res.readEntity(AuthResponse.class); - var actualIdentityProviders = authResponse.identityProviders(); - assertEquals(identityProviders.size(), actualIdentityProviders.size()); - for (int i = 0; i < identityProviders.size(); i++) { - var expected = identityProviders.get(i); - var actual = actualIdentityProviders.get(i); - assertEquals(expected.iss(), actual.iss()); - assertEquals(expected.name(), actual.name()); - assertEquals(expected.logoUrl(), actual.logoUrl()); - } - - var sessionCookie = res.getCookies().get("session_id"); - assertEquals(sessionId, sessionCookie.getValue()); - } - } - @Test void callback_success() { diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/auth/AuthException.java b/ehealthid/src/main/java/com/oviva/ehealthid/auth/AuthException.java new file mode 100644 index 0000000..59a8ac2 --- /dev/null +++ b/ehealthid/src/main/java/com/oviva/ehealthid/auth/AuthException.java @@ -0,0 +1,30 @@ +package com.oviva.ehealthid.auth; + +public class AuthException extends RuntimeException { + + private final Reason reason; + + public AuthException(String message, Reason reason) { + super(message); + this.reason = reason; + } + + public AuthException(String message, Throwable cause, Reason reason) { + super(message, cause); + this.reason = reason; + } + + public Reason reason() { + return reason; + } + + public enum Reason { + UNKNOWN, + INVALID_PAR_URI, + MISSING_PAR_URI, + FAILED_PAR_REQUEST, + MISSING_AUTHORIZATION_URL, + MISSING_OPENID_CONFIGURATION_IN_ENTITY_STATEMENT, + INVALID_ID_TOKEN + } +} diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/auth/AuthExceptions.java b/ehealthid/src/main/java/com/oviva/ehealthid/auth/AuthExceptions.java index a69b424..3acd954 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/auth/AuthExceptions.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/auth/AuthExceptions.java @@ -3,31 +3,44 @@ public class AuthExceptions { private AuthExceptions() {} - public static RuntimeException invalidParRequestUri(String uri) { - return new RuntimeException("invalid par request_uri '%s'".formatted(uri)); + public static AuthException invalidParRequestUri(String uri) { + return new AuthException( + "invalid par request_uri '%s'".formatted(uri), AuthException.Reason.INVALID_PAR_URI); } - public static RuntimeException missingAuthorizationUrl(String sub) { - return new RuntimeException( - "entity statement of '%s' has no authorization url configuration".formatted(sub)); + public static AuthException missingAuthorizationUrl(String sub) { + return new AuthException( + "entity statement of '%s' has no authorization url configuration".formatted(sub), + AuthException.Reason.MISSING_AUTHORIZATION_URL); } - public static RuntimeException missingParUrl(String sub) { - return new RuntimeException( - "entity statement of '%s' has no pushed authorization request configuration" - .formatted(sub)); + public static AuthException missingParUrl(String sub) { + return new AuthException( + "entity statement of '%s' has no pushed authorization request configuration".formatted(sub), + AuthException.Reason.MISSING_PAR_URI); } - public static RuntimeException missingOpenIdConfigurationInEntityStatement(String sub) { - return new RuntimeException( - "entity statement of '%s' lacks openid configuration".formatted(sub)); + public static AuthException failedParRequest(String issuer, Exception cause) { + return new AuthException( + "PAR request failed sub=%s".formatted(issuer), + cause, + AuthException.Reason.FAILED_PAR_REQUEST); } - public static RuntimeException badIdTokenSignature(String issuer) { - return new RuntimeException("bad ID token signature from sub=%s".formatted(issuer)); + public static AuthException missingOpenIdConfigurationInEntityStatement(String sub) { + return new AuthException( + "entity statement of '%s' lacks openid configuration".formatted(sub), + AuthException.Reason.MISSING_OPENID_CONFIGURATION_IN_ENTITY_STATEMENT); } - public static RuntimeException badIdToken(String issuer, Exception cause) { - return new RuntimeException("bad ID token from sub=%s".formatted(issuer), cause); + public static AuthException badIdTokenSignature(String issuer) { + return new AuthException( + "bad ID token signature from sub=%s".formatted(issuer), + AuthException.Reason.INVALID_ID_TOKEN); + } + + public static AuthException badIdToken(String issuer, Exception cause) { + return new AuthException( + "bad ID token from sub=%s".formatted(issuer), cause, AuthException.Reason.INVALID_ID_TOKEN); } } diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/SelectSectoralIdpStepImpl.java b/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/SelectSectoralIdpStepImpl.java index 546704e..8abf6e3 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/SelectSectoralIdpStepImpl.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/SelectSectoralIdpStepImpl.java @@ -8,9 +8,11 @@ import com.oviva.ehealthid.fedclient.IdpEntry; import com.oviva.ehealthid.fedclient.api.EntityStatement; import com.oviva.ehealthid.fedclient.api.EntityStatement.OpenidProvider; +import com.oviva.ehealthid.fedclient.api.HttpException; import com.oviva.ehealthid.fedclient.api.OpenIdClient; import com.oviva.ehealthid.fedclient.api.OpenIdClient.ParResponse; import com.oviva.ehealthid.fedclient.api.ParBodyBuilder; +import com.oviva.ehealthid.util.JsonCodec; import edu.umd.cs.findbugs.annotations.NonNull; import jakarta.ws.rs.core.UriBuilder; import java.net.URI; @@ -80,7 +82,12 @@ public List fetchIdpOptions() { .acrValues("gematik-ehealth-loa-high") .responseType("code"); - var res = doPushedAuthorizationRequest(parBody, trustedIdpEntityStatement.body()); + ParResponse res = null; + try { + res = doPushedAuthorizationRequest(parBody, trustedIdpEntityStatement.body()); + } catch (HttpException | JsonCodec.JsonException e) { + throw AuthExceptions.failedParRequest(sectoralIdpIss, e); + } var redirectUri = buildAuthorizationUrl(res.requestUri(), trustedIdpEntityStatement.body());