Stateless auth not respecting authentication failure

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?

Alright, figured out a fix for this issue as well, but this should really be fixed in the framework so this workaround isn’t required.

I created a auth failure handler which deals with clearing the cookies that were previously set in the response.

package ai.goacquire.frontend.security;

import ai.goacquire.shared.service.UserLoginService;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private static final Logger logger = LoggerFactory.getLogger(CustomAuthenticationFailureHandler.class);

    // Cookie names identified from the browser screenshot
    private static final String JWT_HEADER_PAYLOAD_COOKIE_NAME = "jwt.headerAndPayload";
    private static final String JWT_SIGNATURE_COOKIE_NAME = "jwt.signature";

    // Error type constants to match frontend
    private static final String ERROR_USER_NOT_REGISTERED = "userNotRegistered";
    private static final String ERROR_USER_INACTIVE = "userInactive";
    private static final String ERROR_SYSTEM_ERROR = "systemError";
    private static final String ERROR_LOGIN_FAILED = "loginFailed";

    public CustomAuthenticationFailureHandler() {
        setDefaultFailureUrl("/login?error=" + ERROR_LOGIN_FAILED);
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        String username = request.getParameter("username"); // Or however you get the attempted username
        logger.warn("Authentication failed for user (if available from params): {}. Reason: {}. Clearing JWT cookies.",
                username, exception.getMessage());

        // Clear the JWT cookies
        clearCookie(request, response, JWT_HEADER_PAYLOAD_COOKIE_NAME);
        clearCookie(request, response, JWT_SIGNATURE_COOKIE_NAME);

        String failureUrl = "/login?error=";
        String errorMessage = ERROR_LOGIN_FAILED;

        // Determine specific error type
        if (exception.getCause() instanceof UserLoginService.UserLoginException) {
            UserLoginService.UserLoginException userLoginException = (UserLoginService.UserLoginException) exception.getCause();
            switch (userLoginException.getErrorType()) {
                case NOT_REGISTERED:
                    errorMessage = ERROR_USER_NOT_REGISTERED;
                    break;
                case INACTIVE_USER:
                    errorMessage = ERROR_USER_INACTIVE;
                    break;
                case SYSTEM_ERROR:
                    errorMessage = ERROR_SYSTEM_ERROR;
                    break;
                default:
                    errorMessage = ERROR_LOGIN_FAILED;
                    break;
            }
        } else {
            // Fallback to message parsing for other exception types
            String exceptionMessage = exception.getMessage().toLowerCase();
            if (exceptionMessage.contains("not registered")) {
                errorMessage = ERROR_USER_NOT_REGISTERED;
            } else if (exceptionMessage.contains("inactive")) {
                errorMessage = ERROR_USER_INACTIVE;
            } else if (exceptionMessage.contains("system error")) {
                errorMessage = ERROR_SYSTEM_ERROR;
            }
        }
        
        failureUrl += URLEncoder.encode(errorMessage, StandardCharsets.UTF_8);

        logger.debug("Redirecting to failure URL: {}", failureUrl);
        getRedirectStrategy().sendRedirect(request, response, failureUrl);
    }

    private void clearCookie(HttpServletRequest request, HttpServletResponse response, String cookieName) {
        Cookie cookie = new Cookie(cookieName, null);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(0); // Delete cookie
        response.addCookie(cookie);
        logger.debug("Cleared cookie: {}", cookieName);
    }
}

And then in my SecurityConfig I just setup the failure handler:

package ai.goacquire.frontend.config;

import ai.goacquire.frontend.security.CustomAuthenticationFailureHandler;
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;
    private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;
    @Value("${auth.secret}")
    private String authSecret;

    public SecurityConfig(RouteUtil routeUtil, 
                         OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler,
                         CustomUserAuthoritiesMapper customUserAuthoritiesMapper,
                          CustomAuthenticationFailureHandler customAuthenticationFailureHandler) {
        this.routeUtil = routeUtil;
        this.oAuth2LoginSuccessHandler = oAuth2LoginSuccessHandler;
        this.customUserAuthoritiesMapper = customUserAuthoritiesMapper;
        this.customAuthenticationFailureHandler = customAuthenticationFailureHandler;
    }

    @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)
                .failureHandler(customAuthenticationFailureHandler)
                .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);
    }
}
1 Like