From 0b2303db3e993079509c269605071fbcd611370e Mon Sep 17 00:00:00 2001 From: Tobia De Koninck Date: Fri, 16 Jan 2026 16:25:28 +0100 Subject: [PATCH 01/27] Update copyright year in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fa4d8be2..15aec9d2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ It is the engine that powers a.o. [ShinyProxy](https://shinyproxy.io) but can be Learn more at (in progress) -**(c) Copyright Open Analytics NV, 2017-2025 - Apache License 2.0** +**(c) Copyright Open Analytics NV, 2017-2026 - Apache License 2.0** ## Building from source From 9b17969589ed9b4e9ab9e6f96888ad4610b8651a Mon Sep 17 00:00:00 2001 From: Tobia De Koninck Date: Tue, 20 Jan 2026 10:01:34 +0100 Subject: [PATCH 02/27] Fix #36069: send (updated) OpenID tokens as headers --- pom.xml | 2 +- .../impl/OpenIDAuthenticationBackend.java | 63 ++++++++++++++++++- .../runtime/runtimevalues/HttpHeaders.java | 6 +- .../util/ProxyMappingManager.java | 2 +- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/pom.xml b/pom.xml index c9a76c4d..bb363814 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ eu.openanalytics containerproxy - 1.2.3 + 1.3.0-SNAPSHOT ContainerProxy jar 2016 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/model/runtime/runtimevalues/HttpHeaders.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java index 5320b4ca..d4df7e9c 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,13 @@ 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 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 +54,8 @@ public HttpHeaders(Map headers) { this.headers = filteredHeaders; } - public HeaderMap getUndertowHeaderMap() { + public HeaderMap getUndertowHeaderMap(Proxy proxy) { + undertowHeaderMap.putAll(OpenIDAuthenticationBackend.addHeaders(proxy)); return undertowHeaderMap; } 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); From 114a4c344597c3600ea7f931a2165dda9b61f164 Mon Sep 17 00:00:00 2001 From: Tobia De Koninck Date: Tue, 2 Sep 2025 15:20:00 +0200 Subject: [PATCH 03/27] Fix #35503: add internationalization support --- .../ContainerProxyApplication.java | 2 + .../containerproxy/ui/AuthController.java | 11 +++- .../ui/TemplateResolverConfig.java | 27 ++++++++ .../util/CustomMessageSource.java | 61 +++++++++++++++++++ src/main/resources/cp_messages.properties | 19 ++++++ .../templates/app-access-denied.html | 6 +- src/main/resources/templates/auth-error.html | 11 ++-- src/main/resources/templates/error.html | 2 +- src/main/resources/templates/login.html | 12 ++-- .../resources/templates/logout-success.html | 4 +- 10 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 src/main/java/eu/openanalytics/containerproxy/util/CustomMessageSource.java create mode 100644 src/main/resources/cp_messages.properties diff --git a/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java b/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java index 75f2496b..38b2d09a 100644 --- a/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java +++ b/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java @@ -63,6 +63,7 @@ import org.springframework.session.security.SpringSessionBackedSessionRegistry; import org.springframework.session.web.http.DefaultCookieSerializer; import org.springframework.web.filter.FormContentFilter; +import org.springframework.web.servlet.i18n.SessionLocaleResolver; import javax.annotation.PostConstruct; import javax.inject.Inject; @@ -77,6 +78,7 @@ import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Properties; import java.util.Set; import java.util.concurrent.Executor; diff --git a/src/main/java/eu/openanalytics/containerproxy/ui/AuthController.java b/src/main/java/eu/openanalytics/containerproxy/ui/AuthController.java index 7d6d7d1f..98fcc886 100644 --- a/src/main/java/eu/openanalytics/containerproxy/ui/AuthController.java +++ b/src/main/java/eu/openanalytics/containerproxy/ui/AuthController.java @@ -27,6 +27,8 @@ import eu.openanalytics.containerproxy.event.AuthFailedEvent; import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.core.env.Environment; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; @@ -37,6 +39,7 @@ import org.springframework.web.servlet.view.RedirectView; import javax.inject.Inject; +import java.util.Locale; import java.util.Optional; @Controller @@ -54,14 +57,18 @@ public class AuthController extends BaseController { @Inject private ApplicationEventPublisher applicationEventPublisher; + @Inject + protected MessageSource messageSource; + @RequestMapping(value = "/login", method = RequestMethod.GET) public Object getLoginPage(@RequestParam Optional 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)); } } diff --git a/src/main/java/eu/openanalytics/containerproxy/ui/TemplateResolverConfig.java b/src/main/java/eu/openanalytics/containerproxy/ui/TemplateResolverConfig.java index f438acc3..1911bcfe 100644 --- a/src/main/java/eu/openanalytics/containerproxy/ui/TemplateResolverConfig.java +++ b/src/main/java/eu/openanalytics/containerproxy/ui/TemplateResolverConfig.java @@ -29,10 +29,15 @@ import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.http.CacheControl; +import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.CookieLocaleResolver; +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; +import org.springframework.web.servlet.i18n.SessionLocaleResolver; import org.springframework.web.servlet.resource.ResourceResolver; import org.springframework.web.servlet.resource.ResourceResolverChain; import org.thymeleaf.templateresolver.FileTemplateResolver; @@ -42,6 +47,7 @@ import java.io.IOException; import java.time.Duration; import java.util.List; +import java.util.Locale; @Configuration public class TemplateResolverConfig implements WebMvcConfigurer { @@ -118,6 +124,27 @@ public void configurePathMatch(PathMatchConfigurer configurer) { configurer.setUseTrailingSlashMatch(true); } + @Bean + public LocaleResolver localeResolver() { + CookieLocaleResolver slr = new CookieLocaleResolver(); + // todo cookie secure + slr.setDefaultLocale(Locale.US); + return slr; + } + + // TODO + @Bean + public LocaleChangeInterceptor localeChangeInterceptor() { + LocaleChangeInterceptor lci = new LocaleChangeInterceptor(); + lci.setParamName("lang"); + return lci; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(localeChangeInterceptor()); + } + /** * A resolver that loads the resource and returns the result as a @link ByteArrayResource. * Should be used in combination with @link ResourceChainRegistration where caching is enabled. 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..45612d5c --- /dev/null +++ b/src/main/java/eu/openanalytics/containerproxy/util/CustomMessageSource.java @@ -0,0 +1,61 @@ +/* + * ContainerProxy + * + * Copyright (C) 2016-2025 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 org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.context.MessageSourceProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.context.NoSuchMessageException; +import org.springframework.stereotype.Component; +import org.thymeleaf.util.StringUtils; + +import javax.annotation.Nonnull; +import java.util.Locale; + +@Component("messageSource") +@EnableConfigurationProperties(MessageSourceProperties.class) +public class CustomMessageSource implements MessageSource { + + private MessageSource delegate; + + public CustomMessageSource(MessageSourceProperties properties) { + MessageSourceAutoConfiguration autoConfiguration = new MessageSourceAutoConfiguration(); + this.delegate = autoConfiguration.messageSource(properties); + } + + @Override + public String getMessage(@Nonnull String code, Object[] args, String defaultMessage, @Nonnull Locale locale) { + return StringUtils.capitalize(delegate.getMessage(code, args, defaultMessage, locale)); + } + + @Override + public String getMessage(@Nonnull String code, Object[] args, @Nonnull Locale locale) throws NoSuchMessageException { + return StringUtils.capitalize(delegate.getMessage(code, args, locale)); + } + + @Override + public String getMessage(@Nonnull MessageSourceResolvable resolvable, @Nonnull Locale locale) throws NoSuchMessageException { + return StringUtils.capitalize(delegate.getMessage(resolvable, locale)); + } + +} diff --git a/src/main/resources/cp_messages.properties b/src/main/resources/cp_messages.properties new file mode 100644 index 00000000..d431b1e3 --- /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 user name or password 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.

+

+

+