I’m developing a Spring Boot application deployed behind an AWS API Gateway (HTTP API v2) with Lambda (handler based on SpringBootLambdaContainerHandler and HttpApiV2ProxyRequest).
I’m using OAuth2 with Casdoor, but I’m running into an issue with the state parameter:
state generated by Spring Security.state URL-encoded (= → %3D).state with the stored one, I get: authorization_request_not_found.It works correctly locally. I also tested generating a state without special characters, and in that case the login flow worked behind Lambda/API Gateway.
DEBUG o.s.security.web.FilterChainProxy - Securing GET /oauth2/authorization/casdoor?
DEBUG o.s.s.web.DefaultRedirectStrategy - Redirecting to https://casdoor.example.com/login/oauth/authorize?...&state=abd7CZ2NFOsuFT2ivWcun89d8t7Ndnhn4o08AyrXb6A%3D&redirect_uri=https://api.example.com/login/oauth2/code/casdoor
INFO LambdaContainerHandler - IP xxx.xxx.xxx.xxx -- "GET /oauth2/authorization/casdoor" 302
DEBUG o.s.security.web.FilterChainProxy - Securing GET /login?
INFO LambdaContainerHandler - IP xxx.xxx.xxx.xxx -- "GET /login" 302
DEBUG o.s.security.web.FilterChainProxy - Securing GET /login/oauth2/code/casdoor?code=xxxxx&state=abd7CZ2NFOsuFT2ivWcun89d8t7Ndnhn4o08AyrXb6A=
ERROR SecurityConfig - OAuth2 login FAILURE
org.springframework.security.oauth2.core.OAuth2AuthenticationException: [authorization_request_not_found]
public class LambdaHandler implements RequestStreamHandler {
private static final SpringBootLambdaContainerHandler<HttpApiV2ProxyRequest, AwsProxyResponse>
handler;
static {
try {
handler = SpringBootLambdaContainerHandler.getHttpApiV2ProxyHandler(PojaApplication.class);
} catch (ContainerInitializationException e) {
throw new RuntimeException("Initialization of Spring Boot Application failed", e);
}
}
@Override
public void handleRequest(InputStream input, OutputStream output, Context context)
throws IOException {
handler.proxyStream(input, output, context);
}
}
package com.example.demo.endpoint.rest.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
private final String casdoorClientId;
private final String casdoorLogoutUrl;
public SecurityConfig(
@Value("${spring.security.oauth2.client.registration.casdoor.clientid}")
String casdoorClientId,
@Value("${casdoor.logout.url}") String casdoorLogoutUrl) {
this.casdoorClientId = casdoorClientId;
this.casdoorLogoutUrl = casdoorLogoutUrl;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(Customizer.withDefaults())
.authorizeHttpRequests(
authz ->
authz
.requestMatchers("/casdoor-logout")
.permitAll()
.requestMatchers("/")
.permitAll()
.anyRequest()
.authenticated())
.oauth2Login(
oauth2 ->
oauth2
.successHandler(
(request, response, authentication) -> {
log.info("OAuth2 login SUCCESS");
log.info("User: {}", authentication.getName());
log.info("Authorities: {}", authentication.getAuthorities());
response.sendRedirect("/welcome");
})
.failureHandler(
(request, response, exception) -> {
log.error("OAuth2 login FAILURE");
log.error("Message: {}", exception.getMessage());
new SimpleUrlAuthenticationFailureHandler("/oauth2/authorization/casdoor")
.onAuthenticationFailure(request, response, exception);
log.info("Forced redirect to /oauth2/authorization/casdoor executed");
}));
return http.build();
}
}
I ran the OAuth2 login flow locally, and it worked perfectly: Spring Security generated a state, Casdoor redirected back, and the state was correctly matched.
Behind AWS Lambda + HTTP API v2, using the default generated state (with characters like =), the login fails. Generating a state without special characters works correctly.
I expected Spring Security to automatically handle the state parameter and match it correctly behind Lambda + API Gateway.
What actually happens: the state arrives in the Lambda request, but Spring Security cannot match it with the stored state.
Question:
Could anyone please advise how to automatically handle the encoding/decoding of the state parameter so that Spring Security OAuth2 receives it correctly behind Lambda + HTTP API v2, and the Casdoor authentication works without having to generate the state manually?
Thank you in advance for your help!
Looking at your logs, the problem is that your state parameter is getting mangled somewhere in the flow:
Spring generates: abd7CZ2NFOsuFT2ivWcun89d8t7Ndnhn4o08AyrXb6A=
After redirect: abd7CZ2NFOsuFT2ivWcun89d8t7Ndnhn4o08A=
The ending yrXb6A= is missing, which is why Spring can't match it.
AWS API Gateway has issues with query parameters containing special characters like =. It's either truncating at the = sign or doing some weird double-decoding that corrupts the parameter.
Since you mentioned that states without special characters work fine, the easiest solution is to override Spring's default state generator to produce URL-safe tokens:
@Component
public class CustomAuthorizationRequestRepository
extends HttpSessionOAuth2AuthorizationRequestRepository {
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
HttpServletRequest request,
HttpServletResponse response) {
if (authorizationRequest != null) {
// Replace state with URL-safe version (no = or + characters)
String state = UUID.randomUUID().toString().replace("-", "") +
UUID.randomUUID().toString().replace("-", "");
OAuth2AuthorizationRequest modifiedRequest = OAuth2AuthorizationRequest
.from(authorizationRequest)
.state(state)
.build();
super.saveAuthorizationRequest(modifiedRequest, request, response);
} else {
super.saveAuthorizationRequest(null, request, response);
}
}
}
Then wire it up in your SecurityConfig:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
CustomAuthorizationRequestRepository authRepo)
throws Exception {
http
// ... your existing config ...
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(authorization -> authorization
.authorizationRequestRepository(authRepo))
// ... rest of your oauth2 config
);
return http.build();
}
If you absolutely need to work with the default Base64 states, you can try to fix them on the way back:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class StateFixFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String path = request.getRequestURI();
String state = request.getParameter("state");
// Check if this is the OAuth callback with a potentially broken state
if (path.contains("/login/oauth2/code/") && state != null && !state.endsWith("=")) {
// Try to fix truncated Base64 by adding back the padding
HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request) {
@Override
public String getParameter(String name) {
if ("state".equals(name)) {
// Base64 strings should be multiple of 4 in length
int mod = state.length() % 4;
if (mod > 0) {
return state + "=".repeat(4 - mod);
}
}
return super.getParameter(name);
}
};
chain.doFilter(wrapper, response);
} else {
chain.doFilter(request, response);
}
}
}
But honestly, the first approach (generating alphanumeric-only states) is cleaner and more reliable. API Gateway's query parameter handling can be unpredictable with special characters, so it's better to just avoid them entirely.