When using stateless auth with Hilla, I am having a problem with the JWT token getting passed to the client even if the authentication failed.
E.g. in my LoginSuccessHandler I will throw an exception if the account isn’t found in our system, or the user is inactive. That triggers the failure handlers and clears the Spring context of that user’s info…but since the JWT token is sent to the client it acts like things are fine and the user is able to navigate the app as if they were logged in if they navigate to any of my view URLs directly.
SuccessHandler:
package ai.goacquire.frontend.security;
import ai.goacquire.shared.entity.User;
import ai.goacquire.shared.service.UserLoginService;
import jakarta.servlet.http.Cookie;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Base64;
import java.util.Map;
@Component
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private static final Logger logger = LoggerFactory.getLogger(OAuth2LoginSuccessHandler.class);
private final UserLoginService userLoginService;
public OAuth2LoginSuccessHandler(UserLoginService userLoginService) {
this.userLoginService = userLoginService;
setDefaultTargetUrl("/");
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String provider = determineProvider(request, oAuth2User);
logger.info("Detected provider: {}", provider);
String providerId = getAttributeValue(oAuth2User, "sub", "oid");
String email = getAttributeValue(oAuth2User, "email", "preferred_username", "upn", "mail");
String firstName = getAttributeValue(oAuth2User, "given_name", "givenname", "givenName");
String lastName = getAttributeValue(oAuth2User, "family_name", "surname", "familyname", "lastname");
validateRequiredFields(email, providerId);
logger.info("Attempting login for user email: {}, provider: {}", email, provider);
try {
User user = userLoginService.login(email, firstName, lastName, provider, providerId);
// Handle redirect URI from cookie only on successful login
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if (cookie.getName().equals("REDIRECT_URI")) {
byte[] decodedBytes = Base64.getDecoder().decode(cookie.getValue());
String redirectUri = new String(decodedBytes, "UTF-8");
setDefaultTargetUrl(redirectUri);
logger.info("Set redirect URI from cookie: {}", redirectUri);
break;
}
}
}
// Only call super.onAuthenticationSuccess on successful login
super.onAuthenticationSuccess(request, response, authentication);
} catch (UserLoginService.UserLoginException e) {
logger.warn("Login failed for user: {} - Error type: {} - Message: {}",
email, e.getErrorType(), e.getMessage());
switch (e.getErrorType()) {
case NOT_REGISTERED:
throw new AuthenticationServiceException("User not registered");
case INACTIVE_USER:
throw new AuthenticationServiceException("User inactive");
case SYSTEM_ERROR:
default:
logger.error("System error during login: {}", e.getMessage(), e);
throw new AuthenticationServiceException("Unknown error during login", e);
}
}
}
private String getAttributeValue(OAuth2User oAuth2User, String... possibleKeys) {
Map<String, Object> attributes = oAuth2User.getAttributes();
for (String key : possibleKeys) {
Object value = attributes.get(key);
if (value != null) {
return value.toString();
}
}
return null;
}
private String determineProvider(HttpServletRequest request, OAuth2User oAuth2User) {
// Check URL first
if (request.getRequestURI().contains("google")) {
return "google";
} else if (request.getRequestURI().contains("microsoft")) {
return "microsoft";
}
// Fallback to checking attributes
Map<String, Object> attributes = oAuth2User.getAttributes();
if (attributes.containsKey("iss")) {
String issuer = attributes.get("iss").toString();
logger.info("Found issuer attribute: {}", issuer);
if (issuer.contains("google")) {
return "google";
} else if (issuer.contains("microsoft") || issuer.contains("live.com")) {
return "microsoft";
}
}
logger.error("Unable to determine OAuth2 provider. Request URI: {}, Available attributes: {}",
request.getRequestURI(), attributes.keySet());
throw new IllegalStateException("Unable to determine OAuth2 provider");
}
private void validateRequiredFields(String email, String providerId) {
if (email == null || email.trim().isEmpty()) {
logger.error("Email is required but was not provided by OAuth2 provider");
throw new IllegalStateException("Email is required but was not provided by OAuth2 provider");
}
if (providerId == null || providerId.trim().isEmpty()) {
logger.error("Provider ID is required but was not provided by OAuth2 provider");
throw new IllegalStateException("Provider ID is required but was not provided by OAuth2 provider");
}
}
}
Which is called by AbstractAuthenticationProcessingFilter:
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
this.securityContextRepository.saveContext(context, request, response); // Saved as JWT token here <---
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult); // Called here and throws exception <---
}
Which is called by this other method in the AbstractAuthenticationProcessingFilter:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult);
}
catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
}
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex); // This is called due to my exception <---
}
}
And the SecurityConfig is here:
package ai.goacquire.frontend.config;
import ai.goacquire.frontend.security.OAuth2LoginSuccessHandler;
import ai.goacquire.frontend.security.CustomUserAuthoritiesMapper;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import com.vaadin.hilla.route.RouteUtil;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
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.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
@EnableWebSecurity
@Configuration
@EnableMethodSecurity
public class SecurityConfig extends VaadinWebSecurity {
protected final Log logger = LogFactory.getLog(this.getClass());
private final RouteUtil routeUtil;
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
private final CustomUserAuthoritiesMapper customUserAuthoritiesMapper;
@Value("${auth.secret}")
private String authSecret;
public SecurityConfig(RouteUtil routeUtil,
OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler,
CustomUserAuthoritiesMapper customUserAuthoritiesMapper) {
this.routeUtil = routeUtil;
this.oAuth2LoginSuccessHandler = oAuth2LoginSuccessHandler;
this.customUserAuthoritiesMapper = customUserAuthoritiesMapper;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// First set up all the OAuth and security related paths
http.authorizeHttpRequests(registry -> registry
.requestMatchers(routeUtil::isRouteAllowed).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login/oauth2/authorization/microsoft")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login/oauth2/authorization/google")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/access-denied")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
)
// Configure OAuth2 login with custom user authorities mapper
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.successHandler(oAuth2LoginSuccessHandler)
.userInfoEndpoint(userInfo -> userInfo
.userAuthoritiesMapper(customUserAuthoritiesMapper)
)
);
// Default config to allow Vaadin paths
super.configure(http);
http.sessionManagement(httpSecuritySessionManagementConfigurer ->
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
setLoginView(http, "/login");
// Enable stateless authentication
setStatelessAuthentication(http,
new SecretKeySpec(Base64.getDecoder().decode(authSecret), JwsAlgorithms.HS256)
,"ai.goacquire.frontend"
, 43200);
}
}
This screenshot shows the response cookies after my oauth return request even with the auth failure:
Any ideas on how to solve this?
