Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0b2303d
Update copyright year in README.md
LEDfan Jan 16, 2026
9b17969
Fix #36069: send (updated) OpenID tokens as headers
LEDfan Jan 20, 2026
114a4c3
Fix #35503: add internationalization support
LEDfan Sep 2, 2025
494b658
Added translation using Weblate (Dutch)
LEDfan Sep 3, 2025
e609441
Translated using Weblate (Dutch)
LEDfan Sep 3, 2025
65128b4
Fix #35504: allow user to choose language
LEDfan Sep 3, 2025
770f2e8
Ref #35503: cleanup translations
LEDfan Sep 3, 2025
6670e06
Translated using Weblate (French)
LEDfan Sep 3, 2025
c559e36
Ref #35503: fixes for internationalization
LEDfan Sep 3, 2025
e130ac0
Translated using Weblate (Dutch)
weblate Sep 3, 2025
8562c26
Translated using Weblate (French)
weblate Sep 3, 2025
5657803
Added translation using Weblate (Spanish)
LEDfan Sep 3, 2025
f5911eb
Translated using Weblate (Spanish)
LEDfan Sep 3, 2025
de128aa
Translated using Weblate (French)
LEDfan Nov 10, 2025
f07b119
Always use MessageFormat
LEDfan Nov 10, 2025
016cc55
Add Spanish
LEDfan Nov 10, 2025
949f211
Update license headers and rename directory
LEDfan Jan 14, 2026
5cf4e86
Fix #35505: send lang as header
LEDfan Jan 20, 2026
503fa36
Fix #35505: send lang as env var
LEDfan Jan 20, 2026
8c901e3
Fix #35506: allow to override lang strings
LEDfan Jan 20, 2026
c4d18d9
Fix return type
LEDfan Jan 26, 2026
03d19f0
Fix #36178: show node name in admin panel
LEDfan Jan 26, 2026
1b92567
Ref #35506: fix translation encoding
LEDfan Jan 27, 2026
52c62ab
Fix #36445: make auth-success page work independently of scheme
LEDfan Mar 3, 2026
a8c29fe
Fix #36461: allow to force redirects to use https (!78)
LEDfan Mar 10, 2026
71292e8
Fix #36577: add API endpoint to get current user
LEDfan Apr 3, 2026
0170bc5
Merge branch 'hotfix/1.2.4'
LEDfan Apr 30, 2026
048e1aa
Merge tag 'v1.2.4' into develop
LEDfan Apr 30, 2026
e3c92aa
Bump org.postgresql:postgresql from 42.7.8 to 42.7.11
dependabot[bot] May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ repos:
rev: v1.35.8
hooks:
- id: typos
exclude: 'src/main/resources/cp_messages_.*'
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<docker-client.version>7.0.8-OA-5</docker-client.version>
<javax-annotation-api.version>1.3.2</javax-annotation-api.version>
<monetdb-jdbc.version>12.0</monetdb-jdbc.version>
<postgresql.version>42.7.8</postgresql.version>
<postgresql.version>42.7.11</postgresql.version>
<fabric8-client.version>7.6.1</fabric8-client.version>
<jquery.version>3.7.1</jquery.version>
<fontawesome.version>4.7.0</fontawesome.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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")));
Expand Down Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +50,9 @@ public class BaseController {
@Inject
private UserService userService;

@Inject
private LanguageService languageService;

private String title;
private boolean titleContainsExpression;

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.apache.org/licenses/>
*/
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<ApiResponse<Void>> 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();
}

}
Original file line number Diff line number Diff line change
@@ -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 <http://www.apache.org/licenses/>
*/
package eu.openanalytics.containerproxy.api.dto;

public record LanguageDto(String language) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -180,7 +197,7 @@ public Set<GrantedAuthority> parseClaims(StandardClaimAccessor standardClaimAcce
return mappedAuthorities;
}

private static OAuth2AuthorizedClient refreshClient(String principalName) {
public static OAuth2AuthorizedClient refreshClient(String principalName) {
return oAuth2AuthorizedClientService.loadAuthorizedClient(REG_ID, principalName);
}

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Integer, Integer> portBindings = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -53,7 +55,9 @@ public HttpHeaders(Map<String, String> headers) {
this.headers = filteredHeaders;
}

public HeaderMap getUndertowHeaderMap() {
public HeaderMap getUndertowHeaderMap(Proxy proxy) {
undertowHeaderMap.putAll(OpenIDAuthenticationBackend.addHeaders(proxy));
undertowHeaderMap.putAll(LanguageService.addHeaders());
return undertowHeaderMap;
}

Expand Down
Loading
Loading