diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8cd01ddd..f9ebf8c2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -38,3 +38,4 @@ repos:
rev: v1.35.8
hooks:
- id: typos
+ exclude: 'src/main/resources/cp_messages_.*'
diff --git a/pom.xml b/pom.xml
index af354ea5..7b615440 100644
--- a/pom.xml
+++ b/pom.xml
@@ -34,7 +34,7 @@
7.0.8-OA-51.3.212.0
- 42.7.8
+ 42.7.117.6.13.7.14.7.0
diff --git a/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java b/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java
index 1ae87ef2..22286494 100644
--- a/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java
+++ b/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java
@@ -63,6 +63,8 @@
import org.springframework.session.security.SpringSessionBackedSessionRegistry;
import org.springframework.session.web.http.DefaultCookieSerializer;
import org.springframework.web.filter.FormContentFilter;
+import org.springframework.web.servlet.LocaleResolver;
+import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
@@ -99,6 +101,8 @@ public class ContainerProxyApplication {
private static final String SAME_SITE_COOKIE_DEFAULT_VALUE = "Lax";
private static final String PROP_SERVER_SECURE_COOKIES = "server.secure-cookies";
private static final Boolean SECURE_COOKIES_DEFAULT_VALUE = false;
+ private static final String PROP_FORCE_HTTPS_IN_REDIRECTS = "proxy.force-https-in-redirects";
+ private static final Boolean FORCE_HTTPS_IN_REDIRECTS_DEFAULT_VALUE = false;
private static final Path TERMINATION_LOG_FILE = Path.of("/dev/termination-log");
private static Boolean logAsJson = false;
public static Boolean secureCookiesEnabled;
@@ -333,6 +337,15 @@ public UndertowServletWebServerFactory servletContainer() {
info.setSessionManagerFactory(sessionManagerFactory);
}
info.setResourceManager(EMPTY_RESOURCE_MANAGER);
+ if (environment.getProperty(PROP_FORCE_HTTPS_IN_REDIRECTS, Boolean.class, FORCE_HTTPS_IN_REDIRECTS_DEFAULT_VALUE)) {
+ log.debug("Enforcing redirects to always use https.");
+ info.addInitialHandlerChainWrapper(handler ->
+ exchange -> {
+ exchange.setRequestScheme("https");
+ handler.handleRequest(exchange);
+ }
+ );
+ }
});
try {
factory.setAddress(InetAddress.getByName(environment.getProperty("proxy.bind-address", "0.0.0.0")));
@@ -420,4 +433,14 @@ public GroupedOpenApi groupOpenApi() {
.build();
}
+ @Bean
+ public LocaleResolver localeResolver() {
+ // no default language is configured, therefore the Accept header will determine the language if there is no cookie
+ CookieLocaleResolver cookieLocaleResolver = new CookieLocaleResolver("shinyproxy_language");
+ cookieLocaleResolver.setCookieSecure(secureCookiesEnabled);
+ cookieLocaleResolver.setCookieSameSite(sameSiteCookiePolicy);
+ cookieLocaleResolver.setCookieHttpOnly(true);
+ return cookieLocaleResolver;
+ }
+
}
diff --git a/src/main/java/eu/openanalytics/containerproxy/api/BaseController.java b/src/main/java/eu/openanalytics/containerproxy/api/BaseController.java
index 2b3644b7..fbfacdb3 100644
--- a/src/main/java/eu/openanalytics/containerproxy/api/BaseController.java
+++ b/src/main/java/eu/openanalytics/containerproxy/api/BaseController.java
@@ -21,6 +21,7 @@
package eu.openanalytics.containerproxy.api;
import eu.openanalytics.containerproxy.service.IdentifierService;
+import eu.openanalytics.containerproxy.service.LanguageService;
import eu.openanalytics.containerproxy.service.UserService;
import eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext;
import eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver;
@@ -49,6 +50,9 @@ public class BaseController {
@Inject
private UserService userService;
+ @Inject
+ private LanguageService languageService;
+
private String title;
private boolean titleContainsExpression;
@@ -72,6 +76,7 @@ protected void prepareMap(ModelMap map) {
map.put("request", httpServletRequest);
map.put("response", httpServletResponse);
+ map.put("language", languageService.getAndUpdateLanguageOfUser(httpServletRequest, httpServletResponse));
}
private String getTitle(Authentication user, String serverName) {
diff --git a/src/main/java/eu/openanalytics/containerproxy/api/UserController.java b/src/main/java/eu/openanalytics/containerproxy/api/UserController.java
new file mode 100644
index 00000000..461cc566
--- /dev/null
+++ b/src/main/java/eu/openanalytics/containerproxy/api/UserController.java
@@ -0,0 +1,57 @@
+/*
+ * ContainerProxy
+ *
+ * Copyright (C) 2016-2026 Open Analytics
+ *
+ * ===========================================================================
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the Apache License as published by
+ * The Apache Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * Apache License for more details.
+ *
+ * You should have received a copy of the Apache License
+ * along with this program. If not, see
+ */
+package eu.openanalytics.containerproxy.api;
+
+import eu.openanalytics.containerproxy.api.dto.ApiResponse;
+import eu.openanalytics.containerproxy.api.dto.LanguageDto;
+import eu.openanalytics.containerproxy.service.LanguageService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class UserController extends BaseController {
+
+ private final LanguageService languageService;
+
+ public UserController(LanguageService languageService) {
+ this.languageService = languageService;
+ }
+
+ @RequestMapping(value = "/api/user/language", method = RequestMethod.PUT, produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity> updateUserLanguage(@RequestBody LanguageDto body, HttpServletRequest request, HttpServletResponse response) {
+ try {
+ languageService.updateLanguageOfUser(request, response, body.language());
+ } catch (IllegalArgumentException e) {
+ return ApiResponse.fail(e.getMessage());
+ } catch (IllegalStateException e) {
+ return ApiResponse.error(e);
+ }
+
+ return ApiResponse.success();
+ }
+
+}
diff --git a/src/main/java/eu/openanalytics/containerproxy/api/dto/LanguageDto.java b/src/main/java/eu/openanalytics/containerproxy/api/dto/LanguageDto.java
new file mode 100644
index 00000000..1a4e522b
--- /dev/null
+++ b/src/main/java/eu/openanalytics/containerproxy/api/dto/LanguageDto.java
@@ -0,0 +1,25 @@
+/*
+ * ContainerProxy
+ *
+ * Copyright (C) 2016-2026 Open Analytics
+ *
+ * ===========================================================================
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the Apache License as published by
+ * The Apache Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * Apache License for more details.
+ *
+ * You should have received a copy of the Apache License
+ * along with this program. If not, see
+ */
+package eu.openanalytics.containerproxy.api.dto;
+
+public record LanguageDto(String language) {
+
+}
diff --git a/src/main/java/eu/openanalytics/containerproxy/auth/impl/OpenIDAuthenticationBackend.java b/src/main/java/eu/openanalytics/containerproxy/auth/impl/OpenIDAuthenticationBackend.java
index 558a46fe..45b93994 100644
--- a/src/main/java/eu/openanalytics/containerproxy/auth/impl/OpenIDAuthenticationBackend.java
+++ b/src/main/java/eu/openanalytics/containerproxy/auth/impl/OpenIDAuthenticationBackend.java
@@ -24,9 +24,12 @@
import eu.openanalytics.containerproxy.auth.impl.msgraph.MicrosoftGraphGroupFetcher;
import eu.openanalytics.containerproxy.auth.impl.oidc.AccessTokenDecoder;
import eu.openanalytics.containerproxy.auth.impl.oidc.OpenIdReAuthorizeFilter;
+import eu.openanalytics.containerproxy.model.runtime.Proxy;
import eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext;
import eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver;
import eu.openanalytics.containerproxy.util.ContextPathHelper;
+import io.undertow.util.HeaderMap;
+import io.undertow.util.HttpString;
import net.minidev.json.JSONArray;
import net.minidev.json.parser.JSONParser;
import net.minidev.json.parser.ParseException;
@@ -41,6 +44,7 @@
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
@@ -50,9 +54,11 @@
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.oidc.StandardClaimAccessor;
@@ -83,11 +89,22 @@ public class OpenIDAuthenticationBackend implements IAuthenticationBackend {
public static final String NAME = "openid";
private static final String ENV_TOKEN_NAME = "SHINYPROXY_OIDC_ACCESS_TOKEN";
+ private static final String PROP_SEND_ACCESS_TOKEN_HEADER = "proxy.openid.add-access-token-header";
+ private static final String PROP_SEND_REFRESH_TOKEN_HEADER = "proxy.openid.add-refresh-token-header";
+ private static final String PROP_SEND_ID_TOKEN_HEADER = "proxy.openid.add-id-token-header";
+ private static final HttpString HEADER_ACCESS_TOKEN_NAME = new HttpString("X-SP-OpenId-Access-Token");
+ private static final HttpString HEADER_REFRESH_TOKEN_NAME = new HttpString("X-SP-OpenId-Refresh-Token");
+ private static final HttpString HEADER_ID_TOKEN_NAME = new HttpString("X-SP-OpenId-Id-Token");
+
private static OAuth2AuthorizedClientService oAuth2AuthorizedClientService;
private static AccessTokenDecoder accessTokenDecoder;
private static final Logger log = LogManager.getLogger(OpenIDAuthenticationBackend.class);
- @Inject
- private Environment environment;
+
+ private static Boolean sendAccessTokenHeader = false;
+ private static Boolean sendRefreshTokenHeader = false;
+ private static Boolean sendIdTokenHeader = false;
+ private static Environment environment;
+
@Inject
private ClientRegistrationRepository clientRegistrationRepo;
@Inject
@@ -180,7 +197,7 @@ public Set parseClaims(StandardClaimAccessor standardClaimAcce
return mappedAuthorities;
}
- private static OAuth2AuthorizedClient refreshClient(String principalName) {
+ public static OAuth2AuthorizedClient refreshClient(String principalName) {
return oAuth2AuthorizedClientService.loadAuthorizedClient(REG_ID, principalName);
}
@@ -194,6 +211,14 @@ public void setOAuth2AuthorizedClientService(OAuth2AuthorizedClientService oAuth
OpenIDAuthenticationBackend.oAuth2AuthorizedClientService = oAuth2AuthorizedClientService;
}
+ @Autowired
+ public void setEnvironment(Environment environment) {
+ OpenIDAuthenticationBackend.environment = environment;
+ OpenIDAuthenticationBackend.sendAccessTokenHeader = environment.getProperty(PROP_SEND_ACCESS_TOKEN_HEADER, Boolean.class, false);
+ OpenIDAuthenticationBackend.sendRefreshTokenHeader = environment.getProperty(PROP_SEND_REFRESH_TOKEN_HEADER, Boolean.class, false);
+ OpenIDAuthenticationBackend.sendIdTokenHeader = environment.getProperty(PROP_SEND_ID_TOKEN_HEADER, Boolean.class, false);
+ }
+
@Override
public String getName() {
return NAME;
@@ -350,6 +375,38 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio
};
}
+ public static HeaderMap addHeaders(Proxy proxy) {
+ HeaderMap result = new HeaderMap();
+ if (sendAccessTokenHeader || sendRefreshTokenHeader) {
+ OAuth2AuthorizedClient client = refreshClient(proxy.getUserId());
+ if (client != null) {
+ if (sendAccessTokenHeader) {
+ OAuth2AccessToken accessToken = client.getAccessToken();
+ if (accessToken != null) {
+ result.put(HEADER_ACCESS_TOKEN_NAME, accessToken.getTokenValue());
+ }
+ }
+ if (sendRefreshTokenHeader) {
+ OAuth2RefreshToken refreshToken = client.getRefreshToken();
+ if (refreshToken != null) {
+ result.put(HEADER_REFRESH_TOKEN_NAME, refreshToken.getTokenValue());
+ }
+ }
+ }
+ }
+ if (sendIdTokenHeader) {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth.getPrincipal() instanceof CustomNameOidcUser) {
+ OidcIdToken idToken = ((CustomNameOidcUser) auth.getPrincipal()).getIdToken();
+ if (idToken != null) {
+ result.put(HEADER_ID_TOKEN_NAME, idToken.getTokenValue());
+ }
+ }
+ }
+
+ return result;
+ }
+
public static class CustomNameOidcUser extends DefaultOidcUser {
private static final long serialVersionUID = 7563253562760236634L;
diff --git a/src/main/java/eu/openanalytics/containerproxy/backend/docker/DockerSwarmBackend.java b/src/main/java/eu/openanalytics/containerproxy/backend/docker/DockerSwarmBackend.java
index cf14a2e2..b461834d 100644
--- a/src/main/java/eu/openanalytics/containerproxy/backend/docker/DockerSwarmBackend.java
+++ b/src/main/java/eu/openanalytics/containerproxy/backend/docker/DockerSwarmBackend.java
@@ -32,6 +32,7 @@
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.BackendContainerNameKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ContainerImageKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.InstanceIdKey;
+import eu.openanalytics.containerproxy.model.runtime.runtimevalues.NodeNameKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValueKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.UserIdKey;
@@ -220,6 +221,7 @@ public Proxy startContainer(Authentication user, Container initialContainer, Con
.stream().findAny().orElseThrow(() -> new IllegalStateException("Swarm service has no tasks"));
if (serviceTask.status().containerStatus() != null && serviceTask.status().state().equals("running")) {
rContainerBuilder.id(serviceTask.status().containerStatus().containerId());
+ rContainerBuilder.addRuntimeValue(new RuntimeValue(NodeNameKey.inst, serviceTask.nodeId()), false);
return Retrying.SUCCESS;
} else if (!STARTING_STATES.contains(serviceTask.status().state())) {
slog.warn(proxy, "Docker Swarm container failed: container not running, state reported by docker: " + toJson(serviceTask.status()));
diff --git a/src/main/java/eu/openanalytics/containerproxy/backend/kubernetes/KubernetesBackend.java b/src/main/java/eu/openanalytics/containerproxy/backend/kubernetes/KubernetesBackend.java
index c157e709..12298718 100644
--- a/src/main/java/eu/openanalytics/containerproxy/backend/kubernetes/KubernetesBackend.java
+++ b/src/main/java/eu/openanalytics/containerproxy/backend/kubernetes/KubernetesBackend.java
@@ -38,6 +38,7 @@
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ContainerImageKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ContainerIndexKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.InstanceIdKey;
+import eu.openanalytics.containerproxy.model.runtime.runtimevalues.NodeNameKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ProxiedAppKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ProxyIdKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue;
@@ -398,6 +399,7 @@ public Proxy startContainer(Authentication user, Container initialContainer, Con
Pod pod = kubeClient.resource(startedPod).get();
parseKubernetesEvents(spec.getIndex(), pod, proxyStartupLogBuilder);
+ rContainerBuilder.addRuntimeValue(new RuntimeValue(NodeNameKey.inst, pod.getSpec().getNodeName()), false);
Service service;
Map portBindings = new HashMap<>();
diff --git a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java
index 5320b4ca..e27afce8 100644
--- a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java
+++ b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java
@@ -22,12 +22,14 @@
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
+import eu.openanalytics.containerproxy.auth.impl.OpenIDAuthenticationBackend;
+import eu.openanalytics.containerproxy.model.runtime.Proxy;
+import eu.openanalytics.containerproxy.service.LanguageService;
import io.undertow.util.HeaderMap;
import io.undertow.util.HttpString;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@@ -53,7 +55,9 @@ public HttpHeaders(Map headers) {
this.headers = filteredHeaders;
}
- public HeaderMap getUndertowHeaderMap() {
+ public HeaderMap getUndertowHeaderMap(Proxy proxy) {
+ undertowHeaderMap.putAll(OpenIDAuthenticationBackend.addHeaders(proxy));
+ undertowHeaderMap.putAll(LanguageService.addHeaders());
return undertowHeaderMap;
}
diff --git a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/LanguageKey.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/LanguageKey.java
new file mode 100644
index 00000000..9f5b3696
--- /dev/null
+++ b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/LanguageKey.java
@@ -0,0 +1,49 @@
+/*
+ * ContainerProxy
+ *
+ * Copyright (C) 2016-2026 Open Analytics
+ *
+ * ===========================================================================
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the Apache License as published by
+ * The Apache Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * Apache License for more details.
+ *
+ * You should have received a copy of the Apache License
+ * along with this program. If not, see
+ */
+package eu.openanalytics.containerproxy.model.runtime.runtimevalues;
+
+public class LanguageKey extends RuntimeValueKey {
+
+ public static final LanguageKey inst = new LanguageKey();
+
+ private LanguageKey() {
+ super("openanalytics.eu/sp-language",
+ "SHINYPROXY_LANGUAGE",
+ false,
+ true,
+ true,
+ false, // no need to expose in API
+ true,
+ false,
+ String.class);
+ }
+
+ @Override
+ public String deserializeFromString(String value) {
+ return value;
+ }
+
+ @Override
+ public String serializeToString(String value) {
+ return value;
+ }
+
+}
diff --git a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/NodeNameKey.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/NodeNameKey.java
new file mode 100644
index 00000000..69f905de
--- /dev/null
+++ b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/NodeNameKey.java
@@ -0,0 +1,49 @@
+/*
+ * ContainerProxy
+ *
+ * Copyright (C) 2016-2026 Open Analytics
+ *
+ * ===========================================================================
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the Apache License as published by
+ * The Apache Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * Apache License for more details.
+ *
+ * You should have received a copy of the Apache License
+ * along with this program. If not, see
+ */
+package eu.openanalytics.containerproxy.model.runtime.runtimevalues;
+
+public class NodeNameKey extends RuntimeValueKey {
+
+ public final static NodeNameKey inst = new NodeNameKey();
+
+ private NodeNameKey() {
+ super("openanalytics.eu/sp-node-name",
+ "SHINYPROXY_NODE_NAME",
+ false,
+ false,
+ false,
+ false, // important: may not be exposed in API for security
+ false,
+ true,
+ String.class);
+ }
+
+ @Override
+ public String deserializeFromString(String value) {
+ return value;
+ }
+
+ @Override
+ public String serializeToString(String value) {
+ return value;
+ }
+
+}
diff --git a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/RuntimeValueKeyRegistry.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/RuntimeValueKeyRegistry.java
index 43e18fb2..f120c203 100644
--- a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/RuntimeValueKeyRegistry.java
+++ b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/RuntimeValueKeyRegistry.java
@@ -53,6 +53,8 @@ public class RuntimeValueKeyRegistry {
addRuntimeValueKey(HttpHeadersKey.inst);
addRuntimeValueKey(TargetIdKey.inst);
addRuntimeValueKey(CacheHeadersModeKey.inst);
+ addRuntimeValueKey(LanguageKey.inst);
+ addRuntimeValueKey(NodeNameKey.inst);
}
public static void addRuntimeValueKey(RuntimeValueKey> key) {
diff --git a/src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java b/src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java
index 71b389d9..5c624244 100644
--- a/src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java
+++ b/src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java
@@ -213,6 +213,7 @@ public void handle(HttpServletRequest request, HttpServletResponse response, Acc
.permitAll()
.requestMatchers(
new MvcRequestMatcher(handlerMappingIntrospector, "/login"),
+ new MvcRequestMatcher(handlerMappingIntrospector, "/user/me"),
new MvcRequestMatcher(handlerMappingIntrospector, "/signin/**"),
new MvcRequestMatcher(handlerMappingIntrospector, "/auth-error"),
new MvcRequestMatcher(handlerMappingIntrospector, "/error"),
diff --git a/src/main/java/eu/openanalytics/containerproxy/service/LanguageService.java b/src/main/java/eu/openanalytics/containerproxy/service/LanguageService.java
new file mode 100644
index 00000000..495faa84
--- /dev/null
+++ b/src/main/java/eu/openanalytics/containerproxy/service/LanguageService.java
@@ -0,0 +1,125 @@
+/*
+ * ContainerProxy
+ *
+ * Copyright (C) 2016-2026 Open Analytics
+ *
+ * ===========================================================================
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the Apache License as published by
+ * The Apache Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * Apache License for more details.
+ *
+ * You should have received a copy of the Apache License
+ * along with this program. If not, see
+ */
+package eu.openanalytics.containerproxy.service;
+
+import eu.openanalytics.containerproxy.util.EnvironmentUtils;
+import io.undertow.util.HeaderMap;
+import io.undertow.util.HttpString;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.i18n.LocaleContextHolder;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.LocaleResolver;
+import org.springframework.web.servlet.support.RequestContextUtils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+
+@Service
+public class LanguageService {
+
+ private static final String PROP_PROXY_AVAILABLE_LANGUAGES = "proxy.available-languages";
+ private static final HttpString HEADER_LANGUAGE_NAME = new HttpString("X-SP-Language");
+ private static final Map DEFAULT_AVAILABLE_LANGUAGES = Map.of(
+ "en", new Language("en", "English"),
+ "nl", new Language("nl", "Dutch"),
+ "fr", new Language("fr", "French"),
+ "es", new Language("es", "Spanish")
+ );
+ private static final String PROP_PROXY_FALLBACK_LANGUAGE = "proxy.fallback-language";
+ private static final String DEFAULT_FALLBACK_LANGUAGE = "en";
+
+ private final Map enabledLanguages;
+ private final String fallbackLanguage;
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ public LanguageService(Environment environment) {
+ enabledLanguages = readEnabledLanguages(environment);
+ fallbackLanguage = environment.getProperty(PROP_PROXY_FALLBACK_LANGUAGE, DEFAULT_FALLBACK_LANGUAGE);
+ }
+
+ public void updateLanguageOfUser(HttpServletRequest request, HttpServletResponse response, String language) {
+ if (StringUtils.isBlank(language)) {
+ throw new IllegalArgumentException("No language provided");
+ }
+
+ if (!enabledLanguages.containsKey(language)) {
+ throw new IllegalArgumentException("Requested language not enabled");
+ }
+
+ LocaleResolver localeResolver = RequestContextUtils.getLocaleResolver(request);
+ if (localeResolver == null) {
+ throw new IllegalStateException("Unable to change language");
+ }
+
+ localeResolver.setLocale(request, response, Locale.forLanguageTag(language));
+ }
+
+ public String getAndUpdateLanguageOfUser(HttpServletRequest request, HttpServletResponse response) {
+ String language = LocaleContextHolder.getLocale().getLanguage();
+ if (enabledLanguages.containsKey(language)) {
+ return language;
+ }
+ // language of user is not supported -> use fallback language
+ updateLanguageOfUser(request, response, fallbackLanguage);
+ return fallbackLanguage;
+ }
+
+ private Map readEnabledLanguages(Environment environment) {
+ List configuredLanguages = EnvironmentUtils.readList(environment, PROP_PROXY_AVAILABLE_LANGUAGES);
+ if (configuredLanguages == null) {
+ return DEFAULT_AVAILABLE_LANGUAGES;
+ }
+ Map result = new HashMap<>();
+ for (String key : configuredLanguages) {
+ Language lang = DEFAULT_AVAILABLE_LANGUAGES.get(key);
+ if (lang != null) {
+ result.put(lang.key, lang);
+ } else {
+ logger.warn("Enabled language '{}' is not supported", key);
+ }
+ }
+ return result;
+ }
+
+ public Map getEnabledLanguages() {
+ return enabledLanguages;
+ }
+
+ public record Language(String key, String displayName) {
+
+ }
+
+ public static HeaderMap addHeaders() {
+ HeaderMap result = new HeaderMap();
+ String language = LocaleContextHolder.getLocale().getLanguage();
+ result.add(HEADER_LANGUAGE_NAME, language);
+ return result;
+ }
+
+}
diff --git a/src/main/java/eu/openanalytics/containerproxy/service/ProxyService.java b/src/main/java/eu/openanalytics/containerproxy/service/ProxyService.java
index 3d630704..d5e0956e 100644
--- a/src/main/java/eu/openanalytics/containerproxy/service/ProxyService.java
+++ b/src/main/java/eu/openanalytics/containerproxy/service/ProxyService.java
@@ -37,6 +37,7 @@
import eu.openanalytics.containerproxy.model.runtime.ProxyStartupLog;
import eu.openanalytics.containerproxy.model.runtime.ProxyStatus;
import eu.openanalytics.containerproxy.model.runtime.ProxyStopReason;
+import eu.openanalytics.containerproxy.model.runtime.runtimevalues.LanguageKey;
import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue;
import eu.openanalytics.containerproxy.model.spec.ContainerSpec;
import eu.openanalytics.containerproxy.model.spec.ProxySpec;
@@ -49,6 +50,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Lazy;
+import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.core.env.Environment;
import org.springframework.data.util.Pair;
import org.springframework.security.access.AccessDeniedException;
@@ -318,6 +320,7 @@ public Command startProxy(Authentication user, ProxySpec spec, List error, ModelMap map) {
prepareMap(map);
if (error.isPresent()) {
+ Locale locale = LocaleContextHolder.getLocale();
if (error.get().equals("expired")) {
- map.put("error", "You took too long to login, please try again");
+ map.put("error", messageSource.getMessage("auth.simple.expired_error", null, locale));
} else {
- map.put("error", "Invalid user name or password");
+ map.put("error", messageSource.getMessage("auth.simple.credentials_error", null, locale));
}
}
@@ -77,14 +93,15 @@ public Object getLoginPage(@RequestParam Optional error, ModelMap map) {
@RequestMapping(value = AUTH_SUCCESS_URL, method = RequestMethod.GET)
public String authSuccess(ModelMap map, HttpServletRequest request) {
prepareMap(map);
- map.put("url", ServletUriComponentsBuilder.fromCurrentContextPath().path("/").build().toUriString()); // default url
+ // protocol is added to the url on the client-side
+ map.put("url", ServletUriComponentsBuilder.fromCurrentContextPath().path("/").build().toUriString().replace("https://", "//").replace("http://", "//")); // default url
Object redirectUrl = request.getSession().getAttribute(AUTH_SUCCESS_URL_SESSION_ATTR);
if (redirectUrl instanceof String sRedirectUrl) {
request.getSession().removeAttribute(AUTH_SUCCESS_URL_SESSION_ATTR);
// sanity check: does the redirect url start with the url of this current request
if (sRedirectUrl.startsWith(ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString())) {
- map.put("url", redirectUrl);
+ map.put("url", sRedirectUrl.replace("https://", "//").replace("http://", "//"));
}
}
return "auth-success";
@@ -111,4 +128,23 @@ public String getLogoutSuccessPage(ModelMap map) {
return "logout-success";
}
+
+ @ResponseBody
+ @GetMapping(value = "/user/me", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity>> getUserMetadata() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ boolean isLoggedIn = authentication != null && !(authentication instanceof AnonymousAuthenticationToken) && authentication.isAuthenticated();
+ if (!isLoggedIn) {
+ return ApiResponse.success(
+ Map.of("authenticated", false)
+ );
+ }
+ return ApiResponse.success(
+ Map.of(
+ "authenticated", true,
+ "username", authentication.getName()
+ )
+ );
+ }
+
}
diff --git a/src/main/java/eu/openanalytics/containerproxy/util/CustomMessageSource.java b/src/main/java/eu/openanalytics/containerproxy/util/CustomMessageSource.java
new file mode 100644
index 00000000..f6e8d1ad
--- /dev/null
+++ b/src/main/java/eu/openanalytics/containerproxy/util/CustomMessageSource.java
@@ -0,0 +1,115 @@
+/*
+ * ContainerProxy
+ *
+ * Copyright (C) 2016-2026 Open Analytics
+ *
+ * ===========================================================================
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the Apache License as published by
+ * The Apache Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * Apache License for more details.
+ *
+ * You should have received a copy of the Apache License
+ * along with this program. If not, see
+ */
+package eu.openanalytics.containerproxy.util;
+
+import eu.openanalytics.containerproxy.service.LanguageService;
+import org.apache.commons.lang3.StringUtils;
+import org.jspecify.annotations.Nullable;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.context.support.AbstractMessageSource;
+import org.springframework.context.support.ResourceBundleMessageSource;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Nonnull;
+import java.nio.charset.StandardCharsets;
+import java.text.MessageFormat;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ResourceBundle;
+import java.util.stream.Collectors;
+
+@Component("messageSource")
+public class CustomMessageSource extends AbstractMessageSource {
+
+ private static final String PROP_PROXY_TRANSLATION_OVERRIDES = "proxy.translation-overrides";
+ private final Map> translationOverrides = new HashMap<>();
+ private final Map> translationOverridesMessageFormat = new HashMap<>();
+
+ public CustomMessageSource(Environment environment, LanguageService languageService) {
+ ResourceBundleMessageSource parentMessageSource = new CapitalizedMessageSource();
+ parentMessageSource.setBasenames("messages", "cp_messages");
+ parentMessageSource.setAlwaysUseMessageFormat(true);
+ parentMessageSource.setDefaultEncoding(StandardCharsets.UTF_8.name());
+ setParentMessageSource(parentMessageSource);
+
+ // read overridden translations from config file
+ for (String language : languageService.getEnabledLanguages().keySet()) {
+ Map messages = getTranslationOverrides(environment, language);
+ translationOverrides.put(language, messages);
+ translationOverridesMessageFormat.put(language, convertToMessageFormat(messages, language));
+ }
+ }
+
+ @Override
+ protected @Nullable String resolveCodeWithoutArguments(@Nonnull String code, @Nonnull Locale locale) {
+ if (translationOverrides.containsKey(locale.getLanguage())) {
+ return translationOverrides.get(locale.getLanguage()).get(code);
+ }
+ return null;
+ }
+
+ @Override
+ protected @Nullable MessageFormat resolveCode(@Nonnull String code, @Nonnull Locale locale) {
+ if (translationOverrides.containsKey(locale.getLanguage())) {
+ return translationOverridesMessageFormat.get(locale.getLanguage()).get(code);
+ }
+ return null;
+ }
+
+ private Map getTranslationOverrides(Environment environment, String language) {
+ return Binder.get(environment)
+ .bind(PROP_PROXY_TRANSLATION_OVERRIDES + "." + language, Bindable.mapOf(String.class, String.class))
+ .orElse(new HashMap<>());
+ }
+
+ private Map convertToMessageFormat(Map messages, String language) {
+ Locale locale = Locale.forLanguageTag(language);
+ return messages.entrySet().stream().collect(Collectors.toMap(
+ Map.Entry::getKey,
+ e -> new MessageFormat(StringUtils.capitalize(e.getValue()), locale))
+ );
+ }
+
+ private static class CapitalizedMessageSource extends ResourceBundleMessageSource {
+
+ /**
+ * Capitalizes the first letter of each string. Because this low-level method
+ * is overridden, the strings are still properly cached.
+ *
+ * @param bundle the ResourceBundle to perform the lookup in
+ * @param key the key to look up
+ * @return the associated value, or {@code null} if none
+ */
+ @Override
+ protected @Nullable String getStringOrNull(@Nonnull ResourceBundle bundle, @Nonnull String key) {
+ String result = super.getStringOrNull(bundle, key);
+ if (result != null) {
+ return StringUtils.capitalize(result);
+ }
+ return null;
+ }
+
+ }
+
+}
diff --git a/src/main/java/eu/openanalytics/containerproxy/util/ProxyMappingManager.java b/src/main/java/eu/openanalytics/containerproxy/util/ProxyMappingManager.java
index 888058b8..4a13553a 100644
--- a/src/main/java/eu/openanalytics/containerproxy/util/ProxyMappingManager.java
+++ b/src/main/java/eu/openanalytics/containerproxy/util/ProxyMappingManager.java
@@ -258,7 +258,7 @@ public void dispatchAsync(Proxy proxy, String mapping, HttpServletRequest reques
// add headers
HttpHeaders headers = proxy.getRuntimeObject(HttpHeadersKey.inst);
- exchange.getRequestHeaders().putAll(headers.getUndertowHeaderMap());
+ exchange.getRequestHeaders().putAll(headers.getUndertowHeaderMap(proxy));
exchange.addResponseWrapper((f, exchange1) -> {
proxyCacheHeadersService.addAppCacheHeaders(proxy, exchange1);
diff --git a/src/main/resources/cp_messages.properties b/src/main/resources/cp_messages.properties
new file mode 100644
index 00000000..15ecfb93
--- /dev/null
+++ b/src/main/resources/cp_messages.properties
@@ -0,0 +1,19 @@
+app-access-denied.header=You do not have access to this application.
+app-access-denied.p1=Your account does not have the required roles or groups required for accessing this application.
+app-access-denied.p2=Please contact your administrator if you believe this is a mistake.
+auth-error.header=An error occurred during the authentication procedure.
+auth-error.link=Go back to the main page
+auth-error.message-admin-if=If you are an administrator of {0}
+auth-error.message-admin-text=this error page is typically shown because of an configuration error in the authentication setup. See the logs for more information.
+auth-error.message-user-if=If you are a user of {0}
+auth-error.message-user-text=please report this issue to your administrator and try to log out from your Identity Provider.
+auth.simple.button=Sign in
+auth.simple.header=Please sign in
+auth.simple.password=password
+auth.simple.sign-in-error=Could not sign in!
+auth.simple.username=username
+error.link=Go back to the main page
+logout-success.header=You have been logged out successfully.
+logout-success.link=Login again
+auth.simple.expired_error=You took too long to login, please try again.
+auth.simple.credentials_error=Invalid username or password.
diff --git a/src/main/resources/cp_messages_es.properties b/src/main/resources/cp_messages_es.properties
new file mode 100644
index 00000000..2af550cc
--- /dev/null
+++ b/src/main/resources/cp_messages_es.properties
@@ -0,0 +1,19 @@
+app-access-denied.header=No tienes acceso a esta aplicación.
+app-access-denied.p1=Tu cuenta no tiene los roles o grupos necesarios para acceder a esta aplicación.
+app-access-denied.p2=Si cree que se trata de un error, póngase en contacto con su administrador.
+auth-error.header=Se ha producido un error durante el procedimiento de autenticación.
+auth-error.link=Volver a la página principal
+auth-error.message-admin-if=Si eres administrador de {0}
+auth-error.message-admin-text=Esta página de error suele aparecer debido a un error de configuración autenticación. Consulte los registros para obtener más información.
+auth-error.message-user-if=Si eres usuario de {0}
+auth-error.message-user-text=Por favor, informe de este problema a su administrador e intente cerrar sesión en su proveedor de identidad.
+auth.simple.button=Iniciar sesión
+auth.simple.credentials_error=Nombre de usuario o contraseña no válidos.
+auth.simple.expired_error=Has tardado demasiado en iniciar sesión, por favor, vuelve a intentarlo.
+auth.simple.header=Por favor, inicia sesión
+auth.simple.password=contraseña
+auth.simple.sign-in-error=¡No se pudo iniciar sesión!
+auth.simple.username=nombre de usuario
+error.link=Volver a la página principal
+logout-success.header=Ha cerrado sesión correctamente.
+logout-success.link=Inicie sesión de nuevo
diff --git a/src/main/resources/cp_messages_fr.properties b/src/main/resources/cp_messages_fr.properties
new file mode 100644
index 00000000..f6987c7b
--- /dev/null
+++ b/src/main/resources/cp_messages_fr.properties
@@ -0,0 +1,19 @@
+app-access-denied.header=Vous n''avez pas accès à cette application.
+app-access-denied.p1=Votre compte ne dispose pas des rôles ou groupes requis pour accéder à cette application.
+app-access-denied.p2=Veuillez contacter votre administrateur si vous pensez qu''il s''agit d''une erreur.
+auth-error.header=Une erreur s'est produite pendant la procédure d'authentification.
+auth-error.link=Retour à la page principale
+auth-error.message-admin-if=Si vous êtes administrateur de {0}
+auth-error.message-admin-text=cette page d''erreur s''affiche généralement en raison d''une erreur de configuration dans les paramètres d''authentification. Consultez les journaux pour plus d''informations.
+auth-error.message-user-if=Si vous êtes un utilisateur de {0}
+auth-error.message-user-text=Veuillez signaler ce problème à votre administrateur et essayer de vous déconnecter de votre fournisseur d''identité.
+auth.simple.button=Se connecter
+auth.simple.credentials_error=Nom d''utilisateur ou mot de passe invalide.
+auth.simple.expired_error=Vous avez mis trop de temps à vous connecter, veuillez réessayer.
+auth.simple.header=Veuillez vous connecter
+auth.simple.password=mot de passe
+auth.simple.sign-in-error=Impossible de se connecter !
+auth.simple.username=nom d''utilisateur
+error.link=Retour à la page principale
+logout-success.header=Vous avez été déconnecté avec succès.
+logout-success.link=Se reconnecter
diff --git a/src/main/resources/cp_messages_nl.properties b/src/main/resources/cp_messages_nl.properties
new file mode 100644
index 00000000..487a965d
--- /dev/null
+++ b/src/main/resources/cp_messages_nl.properties
@@ -0,0 +1,19 @@
+app-access-denied.header=U heeft geen toegang tot deze applicatie.
+app-access-denied.p1=Uw account heeft niet de vereiste rollen of groepen om deze applicatie te kunnen gebruiken.
+app-access-denied.p2=Neem contact op met uw beheerder als u denkt dat dit een fout is.
+auth-error.header=Er is een fout opgetreden tijdens de authenticatieprocedure.
+auth-error.link=Ga terug naar de hoofdpagina
+auth-error.message-admin-if=Als u beheerder bent van {0}
+auth-error.message-admin-text=Deze foutpagina wordt meestal weergegeven vanwege een configuratiefout in de authenticatie-instellingen. Raadpleeg de logbestanden voor meer informatie.
+auth-error.message-user-if=Als u een gebruiker bent van {0}
+auth-error.message-user-text=Meld dit probleem aan uw beheerder en probeer uit te loggen bij uw identiteitsprovider.
+auth.simple.button=Aanmelden
+auth.simple.credentials_error=Ongeldige gebruikersnaam of wachtwoord.
+auth.simple.expired_error=Het duurde te lang om aan te melden, probeer het opnieuw.
+auth.simple.header=Meld u aan
+auth.simple.password=paswoord
+auth.simple.sign-in-error=Aanmelden mislukt!
+auth.simple.username=gebruikersnaam
+error.link=Ga terug naar de hoofdpagina
+logout-success.header=U bent succesvol uitgelogd.
+logout-success.link=Opnieuw aanmelden
diff --git a/src/main/resources/templates/app-access-denied.html b/src/main/resources/templates/app-access-denied.html
index 2d051afb..0a8d96b9 100644
--- a/src/main/resources/templates/app-access-denied.html
+++ b/src/main/resources/templates/app-access-denied.html
@@ -38,9 +38,9 @@
-
You do not have access to this application.
-
Your account does not have the required roles or groups required for accessing this application.
-
Please contact your administrator if you believe this is a mistake.