Skip to content

Commit

Permalink
ARC-1243: Handle Internationalisation based on some property (#42)
Browse files Browse the repository at this point in the history
Co-authored-by: Thomas Richner <[email protected]>
  • Loading branch information
1 parent 81f22d3 commit f73950c
Show file tree
Hide file tree
Showing 24 changed files with 863 additions and 100 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ docker run --rm \
-e 'EHEALTHID_RP_REDIRECT_URIS=https://sso-mydiga.example.com/auth/callback' \
-e 'EHEALTHID_RP_ES_TTL=PT5M' \
-e 'EHEALTHID_RP_IDP_DISCOVERY_URI=https://sso-mydiga.example.com/.well-known/openid-configuration' \
-p 1234:1234 \
ghcr.io/oviva-ag/ehealthid-relying-party:latest

#---- 3. register with the federation master
Expand Down
6 changes: 4 additions & 2 deletions cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

set -e

SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )

>&2 echo "INFO compiling"
>&2 mvn --quiet clean package -DskipTests -am -pl=ehealthid-cli
>&2 ./mvnw --quiet package -DskipTests -f "$SCRIPT_DIR" -am -pl=ehealthid-cli
>&2 echo "INFO running cli"
java -jar ./ehealthid-cli/target/ehealthcli.jar "$@"
java -jar "$SCRIPT_DIR/ehealthid-cli/target/ehealthcli.jar" "$@"
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.oviva.ehealthid.fedclient.IdpEntry;
import com.oviva.ehealthid.relyingparty.cfg.RelyingPartyConfig;
import com.oviva.ehealthid.relyingparty.fed.FederationConfig;
import com.oviva.ehealthid.relyingparty.svc.LocalizedException.Message;
import com.oviva.ehealthid.relyingparty.svc.OpenIdErrors.ErrorCode;
import com.oviva.ehealthid.relyingparty.svc.SessionRepo.Session;
import com.oviva.ehealthid.relyingparty.util.IdGenerator;
Expand Down Expand Up @@ -109,7 +110,7 @@ public URI selectedIdentityProvider(@NonNull SelectedIdpRequest request) {

var selectedIdp = request.selectedIdentityProvider();
if (selectedIdp == null || selectedIdp.isBlank()) {
throw new ValidationException("No identity provider selected. Please go back.");
throw new ValidationException(new Message("error.noProvider"));
}

var session = mustFindSession(request.sessionId());
Expand Down Expand Up @@ -137,7 +138,7 @@ public URI callback(@NonNull CallbackRequest request) {

session = removeSession(request.sessionId());
if (session == null) {
throw new ValidationException("Oops, session unknown or expired. Please start again.");
throw new ValidationException(new Message("error.invalidSession"));
}

var issued = tokenIssuer.issueCode(session, idToken);
Expand All @@ -160,14 +161,14 @@ private Session removeSession(@Nullable String id) {

@NonNull
private Session mustFindSession(@Nullable String id) {
var msgNoSessionFound = "Oops, no session unknown or expired. Please start again.";
var localizedMessage = new Message("error.invalidSession");
if (id == null || id.isBlank()) {
throw new ValidationException(msgNoSessionFound);
throw new ValidationException(localizedMessage);
}

var session = sessionRepo.load(id);
if (session == null) {
throw new ValidationException(msgNoSessionFound);
throw new ValidationException(localizedMessage);
}
return session;
}
Expand All @@ -177,33 +178,40 @@ private void validateAuthorizationRequest(AuthorizationRequest request) {
var redirect = request.redirectUri();

if (redirect == null) {
throw new ValidationException("no redirect_uri");
throw new ValidationException(new Message("error.noRedirect"));
}

if (!"https".equals(redirect.getScheme())) {
throw new ValidationException(
"Insecure redirect_uri='%s'. Misconfigured server, please use 'https'."
.formatted(redirect));
var localizedMessage = new Message("error.insecureRedirect", redirect.toString());
throw new ValidationException(localizedMessage);
}

if (!relyingPartyConfig.validRedirectUris().contains(redirect)) {
throw new ValidationException(
"Untrusted redirect_uri=%s. Misconfigured server.".formatted(redirect));
var localizedMessage = new Message("error.untrustedRedirect", redirect.toString());
throw new ValidationException(localizedMessage);
}

if (!"openid".equals(request.scope())) {
var msg = "scope '%s' not supported".formatted(request.scope());
var localizedErrorMessage = new Message("error.unsupportedScope", request.scope());
var uri =
OpenIdErrors.redirectWithError(redirect, ErrorCode.INVALID_SCOPE, request.state(), msg);
throw new ValidationException(msg, uri);
OpenIdErrors.redirectWithError(
redirect,
ErrorCode.INVALID_SCOPE,
request.state(),
localizedErrorMessage.messageKey());
throw new ValidationException(localizedErrorMessage, uri);
}

if (!relyingPartyConfig.supportedResponseTypes().contains(request.responseType())) {
var msg = "unsupported response type: '%s'".formatted(request.responseType());
var localizedErrorMessage =
new Message("error.unsupportedResponseType", request.responseType());
var uri =
OpenIdErrors.redirectWithError(
redirect, ErrorCode.UNSUPPORTED_RESPONSE_TYPE, request.state(), msg);
throw new ValidationException(msg, uri);
redirect,
ErrorCode.UNSUPPORTED_RESPONSE_TYPE,
request.state(),
localizedErrorMessage.messageKey());
throw new ValidationException(localizedErrorMessage, uri);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.oviva.ehealthid.relyingparty.svc;

public interface LocalizedException {

Message localizedMessage();

record Message(String messageKey, String... args) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,33 @@

import java.net.URI;

public class ValidationException extends RuntimeException {
public class ValidationException extends RuntimeException implements LocalizedException {

private final URI seeOther;

public ValidationException(String message) {
this(message, null, null);
private final transient Message localizedMessage;

public ValidationException(Message localizedMessage, URI seeOther) {
this(localizedMessage.messageKey(), null, seeOther, null);
}

public ValidationException(String message, URI seeOther) {
this(message, null, seeOther);
public ValidationException(Message localizedMessage) {
this(localizedMessage.messageKey(), null, null, localizedMessage);
}

public ValidationException(String message, Throwable cause, URI seeOther) {
public ValidationException(
String message, Throwable cause, URI seeOther, Message localizedMessage) {
super(message, cause);
this.seeOther = seeOther;
this.localizedMessage = localizedMessage;
}

public URI seeOther() {
return seeOther;
}

@Override
public LocalizedException.Message localizedMessage() {
return localizedMessage;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.oviva.ehealthid.relyingparty.util;

import com.oviva.ehealthid.relyingparty.svc.LocalizedException.Message;
import com.oviva.ehealthid.relyingparty.svc.ValidationException;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.Set;

public class LocaleUtils {

public static final String BUNDLE = "i18n";
public static final Locale DEFAULT_LOCALE = Locale.GERMANY;
protected static Set<Locale> supportedLocales = loadSupportedLocales();

private LocaleUtils() {}

public static Locale getNegotiatedLocale(String acceptLanguageHeaderValue) {

var acceptableLanguages = negotiatePreferredLocales(acceptLanguageHeaderValue);

return acceptableLanguages.stream().findFirst().orElse(DEFAULT_LOCALE);
}

static List<Locale> negotiatePreferredLocales(String headerValue) {

if (headerValue == null || headerValue.isBlank()) {
headerValue = DEFAULT_LOCALE.toLanguageTag();
}

try {
var languageRanges = Locale.LanguageRange.parse(headerValue);
return Locale.filter(languageRanges, supportedLocales);
} catch (IllegalArgumentException e) {
throw new ValidationException(new Message("error.unparsableHeader"));
}
}

public static String formatLocalizedErrorMessage(Message localizedErrorMessage, Locale locale) {
var bundle = ResourceBundle.getBundle(BUNDLE, locale);
var localizedMessage = bundle.getString(localizedErrorMessage.messageKey());

var key = localizedErrorMessage.messageKey();
if (!key.isBlank()) {
localizedMessage = localizedMessage.formatted((Object[]) localizedErrorMessage.args());
}
return localizedMessage;
}

public static Set<Locale> loadSupportedLocales() {
var locales = new HashSet<Locale>();
var control = ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES);
for (Locale locale : Locale.getAvailableLocales()) {
try {
var bundle = ResourceBundle.getBundle(BUNDLE, locale, control);
if (bundle.getLocale().equals(locale)) {
locales.add(locale);
}
} catch (MissingResourceException e) {
// left empty on purpose
// Skip adding this locale
}
}
return locales;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.oviva.ehealthid.relyingparty.ws;

import static com.oviva.ehealthid.relyingparty.svc.LocalizedException.Message;
import static com.oviva.ehealthid.relyingparty.util.LocaleUtils.getNegotiatedLocale;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.oviva.ehealthid.relyingparty.svc.AuthService;
import com.oviva.ehealthid.relyingparty.svc.AuthService.AuthorizationRequest;
Expand All @@ -13,8 +16,10 @@
import edu.umd.cs.findbugs.annotations.Nullable;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.CookieParam;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
Expand Down Expand Up @@ -47,15 +52,17 @@ public Response auth(
@QueryParam("response_type") String responseType,
@QueryParam("client_id") String clientId,
@QueryParam("redirect_uri") String redirectUri,
@QueryParam("nonce") String nonce) {
@QueryParam("nonce") String nonce,
@HeaderParam("Accept-Language") @DefaultValue("de-DE") String acceptLanguage) {

var uri = mustParse(redirectUri);

var res =
authService.auth(
new AuthorizationRequest(scope, state, responseType, clientId, uri, nonce));

var form = pages.selectIdpForm(res.identityProviders());
var locale = getNegotiatedLocale(acceptLanguage);
var form = pages.selectIdpForm(res.identityProviders(), locale);

return Response.ok(form, MediaType.TEXT_HTML_TYPE)
.cookie(createSessionCookie(res.sessionId()))
Expand Down Expand Up @@ -93,12 +100,14 @@ public Response authJson(
@NonNull
private URI mustParse(@Nullable String uri) {
if (uri == null || uri.isBlank()) {
throw new ValidationException("blank uri");
var localizedMessage = new Message("error.blankUri");
throw new ValidationException(localizedMessage);
}
try {
return new URI(uri);
} catch (URISyntaxException e) {
throw new ValidationException("bad uri='%s'".formatted(uri));
var localizedMessage = new Message("error.badUri", uri);
throw new ValidationException(localizedMessage);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.oviva.ehealthid.relyingparty.ws;

import static com.oviva.ehealthid.relyingparty.svc.ValidationException.*;
import static com.oviva.ehealthid.relyingparty.util.LocaleUtils.getNegotiatedLocale;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.oviva.ehealthid.relyingparty.svc.AuthenticationException;
import com.oviva.ehealthid.relyingparty.svc.ValidationException;
Expand Down Expand Up @@ -27,8 +30,7 @@

public class ThrowableExceptionMapper implements ExceptionMapper<Throwable> {

private static final String SERVER_ERROR_MESSAGE =
"Ohh no! Unexpected server error. Please try again.";
private static final String SERVER_ERROR_MESSAGE = "error.serverError";
private final Pages pages = new Pages(new TemplateRenderer());
@Context UriInfo uriInfo;
@Context Request request;
Expand All @@ -41,7 +43,6 @@ public class ThrowableExceptionMapper implements ExceptionMapper<Throwable> {

@Override
public Response toResponse(Throwable exception) {

if (exception instanceof WebApplicationException w) {
var res = w.getResponse();
if (res.getStatus() >= 500) {
Expand All @@ -59,30 +60,34 @@ public Response toResponse(Throwable exception) {
return Response.seeOther(ve.seeOther()).build();
}

return buildContentNegotiatedErrorResponse(ve.getMessage(), Status.BAD_REQUEST);
return buildContentNegotiatedErrorResponse(ve.localizedMessage(), Status.BAD_REQUEST);
}

log(exception);

var status = determineStatus(exception);

return buildContentNegotiatedErrorResponse(SERVER_ERROR_MESSAGE, status);
var errorMessage = new Message(SERVER_ERROR_MESSAGE, (String) null);
return buildContentNegotiatedErrorResponse(errorMessage, status);
}

private Response buildContentNegotiatedErrorResponse(String message, StatusType status) {
private Response buildContentNegotiatedErrorResponse(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);
var body = pages.error(message, locale);
return Response.status(status).entity(body).type(MediaType.TEXT_HTML_TYPE).build();
}

if (MediaType.APPLICATION_JSON_TYPE.equals(mediaType)) {
var body = new Problem("/server_error", message);
var body = new Problem("/server_error", message.messageKey());
return Response.status(status).entity(body).type(MediaType.APPLICATION_JSON_TYPE).build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package com.oviva.ehealthid.relyingparty.ws.ui;

import static com.oviva.ehealthid.relyingparty.util.LocaleUtils.*;

import com.oviva.ehealthid.fedclient.IdpEntry;
import com.oviva.ehealthid.relyingparty.svc.LocalizedException.Message;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public class Pages {
Expand All @@ -13,14 +17,18 @@ public Pages(TemplateRenderer renderer) {
this.renderer = renderer;
}

public String selectIdpForm(List<IdpEntry> identityProviders) {
public String selectIdpForm(List<IdpEntry> identityProviders, Locale locale) {
identityProviders =
identityProviders.stream().sorted(Comparator.comparing(IdpEntry::name)).toList();

return renderer.render(
"select-idp.html.mustache", Map.of("identityProviders", identityProviders));
"select-idp.html.mustache", Map.of("identityProviders", identityProviders), locale);
}

public String error(String message) {
return renderer.render("error.html.mustache", Map.of("message", message));
public String error(Message errorMessage, Locale locale) {
var localizedErrorMessage = formatLocalizedErrorMessage(errorMessage, locale);

return renderer.render(
"error.html.mustache", Map.of("errorMessage", localizedErrorMessage), locale);
}
}
Loading

0 comments on commit f73950c

Please sign in to comment.