Skip to content

Commit

Permalink
EPA-171: Removed JSON content-type for /auth and improved error messages
Browse files Browse the repository at this point in the history
  • Loading branch information
thomasrichner-oviva committed Dec 4, 2024
1 parent e46698f commit a6ab26d
Show file tree
Hide file tree
Showing 8 changed files with 115 additions and 141 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -33,15 +34,13 @@ public class ThrowableExceptionMapper implements ExceptionMapper<Throwable> {

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
Expand All @@ -66,55 +65,46 @@ 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
log(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) {
Expand Down
23 changes: 12 additions & 11 deletions ehealthid-rp/src/main/resources/i18n_de_DE.properties
Original file line number Diff line number Diff line change
@@ -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
23 changes: 12 additions & 11 deletions ehealthid-rp/src/main/resources/i18n_en_US.properties
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@
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;
import com.oviva.ehealthid.relyingparty.svc.AuthService.CallbackRequest;
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;
Expand Down Expand Up @@ -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() {

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,7 +82,12 @@ public List<IdpEntry> 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());

Expand Down

0 comments on commit a6ab26d

Please sign in to comment.