After further testing, it seems that it is vaadin.url-mapping
property that does not agree with this. And my debugging had been a goose-chase around that.
I have a working config with vaadin on root (not wanted).
import com.vaadin.flow.spring.security.VaadinWebSecurity
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer
import org.springframework.security.config.http.SessionCreationPolicy
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.oauth2.core.oidc.user.OidcUserAuthority
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import java.time.Duration
import java.util.stream.Collectors
import java.util.stream.Stream
@Configuration
@EnableMethodSecurity
@Order(1)
class ApiSecurityConfig {
@Bean
fun corsConfigurationSource(): UrlBasedCorsConfigurationSource {
val configuration = CorsConfiguration()
configuration.allowedOriginPatterns = listOf("https://*.com*")
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE")
configuration.allowedHeaders = listOf("Content-Type")
configuration.allowCredentials = true
configuration.setMaxAge(Duration.ofDays(1))
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration)
return source
}
@Bean
fun webSecurityCustomizer(): WebSecurityCustomizer {
return WebSecurityCustomizer { web ->
web.debug(true)
}
}
@Throws(Exception::class)
@Bean
fun standardFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.securityMatcher(
"/api/**",
"/actuator/**",
"/swagger-ui.html",
"/swagger-ui/**",
"/v3/api-docs",
"/v3/api-docs/**"
)
.authorizeHttpRequests { // Authorize special paths
it.requestMatchers(
"/api/deposit/*/form/marc",
"/api/external/**",
"/actuator/health",
"/actuator/health/*",
"/actuator/info",
"/actuator/metrics",
"/actuator/prometheus",
"/swagger-ui/",
"/swagger-ui/*",
"/swagger-ui.html",
"/v3/api-docs",
"/v3/api-docs/**",
).permitAll()
}
.authorizeHttpRequests { it.anyRequest().authenticated() }
.cors { it.configurationSource(corsConfigurationSource()) }
.csrf { it.disable() }
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
} .sessionManagement { sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
return http.build()
}
@Bean
fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
val grantedAuthoritiesConverter = JwtGrantedAuthoritiesConverter()
grantedAuthoritiesConverter.setAuthoritiesClaimName("groups")
grantedAuthoritiesConverter.setAuthorityPrefix("")
val jwtAuthenticationConverter = JwtAuthenticationConverter()
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter)
return jwtAuthenticationConverter
}
}
@Order(2)
@EnableWebSecurity
@Configuration
class VaadinSecurityConfig: VaadinWebSecurity() {
override fun configure(http: HttpSecurity) {
super.configure(http)
setOAuth2LoginPage(http, "/oauth2/authorization/keycloak")
http.oauth2Login { oauth2 ->
oauth2.userInfoEndpoint { userInfoEndpoint -> userInfoEndpoint.userAuthoritiesMapper(this.userAuthoritiesMapper()) }
} http.logout {
it.logoutSuccessHandler(HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
}
}
@Bean(name = ["VaadinSecurityFilterChainBean"])
override fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.securityMatcher(
"/", // Root path for Vaadin
"/png-resetter", // A view in Vaadin Hilla React
"/login/**",
"/oauth2/**",
"/ui/connect/**",
"/ui/**",
"/ui",
"/connect/**",
"/VAADIN/**"
)
return super.filterChain(http)
}
@Bean
fun userAuthoritiesMapper(): GrantedAuthoritiesMapper {
return GrantedAuthoritiesMapper { authorities: Collection<GrantedAuthority?> ->
authorities.stream()
.filter { authority: GrantedAuthority? -> OidcUserAuthority::class.java.isInstance(authority) }
.map { authority: GrantedAuthority? ->
val oidcUserAuthority = authority as OidcUserAuthority?
val userInfo = oidcUserAuthority!!.userInfo
val roles = userInfo.getClaim<List<String>>("groups")
roles.stream().map { r: String? -> SimpleGrantedAuthority("ROLE_$r") }
} .reduce(Stream.empty()) { joinedAuthorities: Stream<SimpleGrantedAuthority>, roleAuthorities: Stream<SimpleGrantedAuthority> ->
Stream.concat(
joinedAuthorities,
roleAuthorities
)
}
.collect(Collectors.toList())
}
}
}
When i set
vaadin:
url-mapping: /ui/*
My services return HTML. ex
POST http://localhost:PORT/backend/ui/connect/AdminService/getUserInfo
Response:
<!doctype html><!--
This file is auto-generated by Vaadin.
-->
<html lang="en">
<head>
<script initial="">window.Vaadin = window.Vaadin || {};window.Vaadin.VaadinLicenseChecker = { maybeCheck: (productInfo) => { }};window.Vaadin.devTools = window.Vaadin.devTools || {};window.Vaadin.devTools.createdCvdlElements = window.Vaadin.devTools.createdCvdlElements || [];window.Vaadin.originalCustomElementDefineFn = window.Vaadin.originalCustomElementDefineFn || window.customElements.define;window.customElements.define = function (tagName, constructor, ...args) {const { cvdlName, version } = constructor;if (cvdlName && version) { const { connectedCallback } = constructor.prototype; constructor.prototype.connectedCallback = function () { window.Vaadin.devTools.createdCvdlElements.push(this); if (connectedCallback) { connectedCallback.call(this); } }}window.Vaadin.originalCustomElementDefineFn.call(this, tagName, constructor, ...args);};</script>
<script initial="">window.Vaadin = window.Vaadin || {};window.Vaadin.ConsoleErrors = window.Vaadin.ConsoleErrors || [];const browserConsoleError = window.console.error.bind(window.console);console.error = (...args) => { browserConsoleError(...args); window.Vaadin.ConsoleErrors.push(args);};window.onerror = (message, source, lineno, colno, error) => {const location=source+':'+lineno+':'+colno;window.Vaadin.ConsoleErrors.push([message, '('+location+')']);};window.addEventListener('unhandledrejection', e => { window.Vaadin.ConsoleErrors.push([e.reason]);});</script>
<script initial="">window.Vaadin.devToolsPlugins = [];
window.Vaadin.devToolsConf = {"enable":true,"url":"./../ui/VAADIN/push","backend":"SPRING_BOOT_DEVTOOLS","liveReloadPort":35729,"token":"9e7c9c5f-6f92-4f6a-886d-c7b817043182"};
</script>
<script initial="">window.Vaadin = window.Vaadin || {};
window.Vaadin.developmentMode = true;
</script>
<script initial="">if (!('CSSLayerBlockRule' in window)) {
window.location.search='v-r=oldbrowser';
}
</script>
<script initial="">window.Vaadin = window.Vaadin || {};window.Vaadin.TypeScript= {};</script>
<meta name="_csrf_parameter" content="_csrf">
<meta name="_csrf_header" content="X-CSRF-TOKEN">
<meta name="_csrf" content="Hm-oxtCi-fbrK4mGCL4CEV0ok0jNkpIKS2byt16ZHO6ie7dnLAqQ8-HEyMbGGb3gOpM2dG1Mvir08KsnKgCQ0marKNrAGoFU">
<script type="module">const csrfParameterName = '_csrf';
const csrfCookieName = 'XSRF-TOKEN';
window.addEventListener('formdata', (e) => {
if (!e.formData.has(csrfParameterName)) {
return;
}
const cookies = new URLSearchParams(document.cookie.replace(/;\s*/, '&'));
if (!cookies.has(csrfCookieName)) {
return;
}
e.formData.set(csrfParameterName, cookies.get(csrfCookieName));
});
</script>
<script initial="">window.Vaadin = window.Vaadin || {};
window.Vaadin.featureFlagsUpdaters = window.Vaadin.featureFlagsUpdaters || [];
window.Vaadin.featureFlagsUpdaters.push((activator) => {
});</script><base href="./../..">
<script type="text/javascript">window.JSCompiler_renameProperty = function(a) { return a;}</script>
<script type="module">
import { inject } from "/backend/ui/VAADIN/@vite-plugin-checker-runtime";
inject({
overlayConfig: {},
base: "/backend/ui/VAADIN/",
});
</script>
<script type="module">
import RefreshRuntime from "/backend/ui/VAADIN/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="/backend/ui/VAADIN/@vite/client"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body, #outlet {
height: 100vh;
width: 100%;
margin: 0;
}
</style><!-- index.ts is included here automatically (either by the dev server or during the build) -->
<script type="module" src="/backend/ui/VAADIN/generated/vite-devmode.ts" onerror="document.location.reload()"></script>
<script type="module" src="/backend/ui/VAADIN/generated/vaadin.ts"></script>
<style>.v-reconnect-dialog,.v-system-error {position: absolute;color: black;background: white;top: 1em;right: 1em;border: 1px solid black;padding: 1em;z-index: 10000;max-width: calc(100vw - 4em);max-height: calc(100vh - 4em);overflow: auto;} .v-system-error {color: indianred;pointer-events: auto;} .v-system-error h3, .v-system-error b {color: red;}</style>
<style>[hidden] { display: none !important; }</style><!--CSSImport end--><!--Stylesheet end-->
<script initial="" src="./../../VAADIN/static/push/vaadinPush.js"></script>
<script>window.Vaadin = window.Vaadin ?? {};
window.Vaadin.views = {"":{"title":"Main","rolesAllowed":null,"loginRequired":true,"route":"","lazy":false,"register":false,"menu":{"title":null,"order":null,"exclude":true,"icon":null,"menuClass":null},"flowLayout":false,"params":{}},"/png-deposit-resetter":{"title":"Png Resetter","rolesAllowed":["ROLE_T_dimo_admin"],"loginRequired":true,"route":"png-deposit-resetter","lazy":false,"register":false,"menu":{"title":"Png Resetter","order":1.0,"exclude":false,"icon":"vaadin:step-backward","menuClass":null},"flowLayout":false,"params":{}}};</script>
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
<script>window.Vaadin = window.Vaadin || {};
window.Vaadin.registrations = window.Vaadin.registrations || [];
window.Vaadin.registrations.push({"is":"flow/SpringInstantiator","version":"24.8.0.alpha8"},{"is":"flow/hotdeploy","version":"24.8.0.alpha8"},{"is":"java","version":"21.0.6"},{"is":"SpringFramework","version":"6.1.13"},{"is":"has-endpoint","version":"24.8.0.alpha5"},{"is":"has-react","version":"24.8.0.alpha5"},{"is":"hilla","version":"24.8.0.alpha5"},{"is":"SpringBoot","version":"3.3.4"},{"is":"has-hilla-fs-route","version":"24.8.0.alpha5"});</script><vaadin-dev-tools></vaadin-dev-tools>
</body>
</html>
And with logs in the backend:
2025-06-03T16:49:07.713+02:00 INFO 3139147 --- [nio-8082-exec-4] Spring Security Debugger :
************************************************************
Request received for POST '/ui/connect/AdminService/getUserInfo':
org.apache.catalina.connector.RequestFacade@16e2a778
servletPath:/ui
pathInfo:/connect/AdminService/getUserInfo
headers:
host: localhost:8082
user-agent: Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0
accept: application/json
accept-language: en-US,en;q=0.5
accept-encoding: gzip, deflate, br, zstd
referer: http://localhost:8082/backend/ui/
x-csrf-token: <redacted>
content-type: application/json
content-length: 2
origin: http://localhost:8082
dnt: 1
sec-gpc: 1
connection: keep-alive
cookie: JSESSIONID=<redacted>; csrfToken=<redacted>
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-origin
priority: u=4
Security filter chain: [
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter
HeaderWriterFilter
CorsFilter
CsrfFilter
LogoutFilter
OAuth2AuthorizationRequestRedirectFilter
OAuth2LoginAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
ExceptionTranslationFilter
AuthorizationFilter
]
************************************************************
2025-06-03T16:49:07.713+02:00 DEBUG 3139147 --- [nio-8082-exec-4] o.s.security.web.FilterChainProxy : Securing POST /ui/connect/AdminService/getUserInfo
2025-06-03T16:49:07.713+02:00 DEBUG 3139147 --- [nio-8082-exec-4] w.c.HttpSessionSecurityContextRepository : Retrieved SecurityContextImpl [Authentication=OAuth2AuthenticationToken <redacted>]
2025-06-03T16:49:07.717+02:00 DEBUG 3139147 --- [nio-8082-exec-4] o.s.security.web.FilterChainProxy : Secured POST /ui/connect/AdminService/getUserInfo
2025-06-03T16:49:07.717+02:00 DEBUG 3139147 --- [nio-8082-exec-4] c.v.b.devserver.AbstractDevServerRunner : Requesting resource from Vite http://127.0.0.1:35997/backend/ui/connect/AdminService/getUserInfo
2025-06-03T16:49:07.719+02:00 DEBUG 3139147 --- [nio-8082-exec-4] c.v.b.devserver.AbstractDevServerRunner : Resource not served by Vite /connect/AdminService/getUserInfo
So is there some “trick” i need to be able to host vaadin on “/ui” and not root?