spring-bootkotlindiscordspring-oauth2

No user context present after finishing Oauth2 flow


I am trying to implement single sign-on with Spring Boot 3 / Kotlin and two providers: Google and Discord. Google works out of the box but I am struggling with Discord.

My properties:

# OAuth2 - Google
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google

# OAuth2 - Discord
spring.security.oauth2.client.registration.discord.provider=discord
spring.security.oauth2.client.registration.discord.client-id=${DISCORD_CLIENT_ID}
spring.security.oauth2.client.registration.discord.client-secret=${DISCORD_CLIENT_SECRET}
spring.security.oauth2.client.registration.discord.client-authentication-method=client_secret_post
spring.security.oauth2.client.registration.discord.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.discord.scope[0]=identify
spring.security.oauth2.client.registration.discord.scope[1]=email
spring.security.oauth2.client.registration.discord.redirect-uri=http://localhost:8080/login/oauth2/code/discord
spring.security.oauth2.client.provider.discord.authorization-uri=https://discordapp.com/api/oauth2/authorize
spring.security.oauth2.client.provider.discord.token-uri=https://discordapp.com/api/oauth2/token
spring.security.oauth2.client.provider.discord.user-info-uri=https://discordapp.com/api/users/@me
spring.security.oauth2.client.provider.discord.user-name-attribute=username

Security config:

@Configuration
class SecurityConfig {

    @Value("\${com.xxx.frontend.url}")
    private val frontendUrl: String = "http://localhost:3000"

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .csrf { csrf -> csrf.disable() } // Disable CSRF for simplicity
            .authorizeHttpRequests { authorize ->
                authorize
                    .requestMatchers("/", "/login", "/graphql", "/graphiql").permitAll()
                    .anyRequest().authenticated()
            }
            .oauth2Login { oauth2Login -> oauth2Login.defaultSuccessUrl(frontendUrl, true) }
            .logout { logout -> logout.logoutSuccessUrl(frontendUrl).permitAll() }
        return http.build()
    }

}

User info endpoint:

@Secured
    @QueryMapping
    fun userInfo(@AuthenticationPrincipal oidcUser: OidcUser?): User? {
      // perform some action with `oidcUser`
      val user = someAction(oidcUser)
      return user;
    }

When logging with Google oidcUser is present after redirection but for Discord it is missing even though the flow finished successfully and there are no errors in my log. What am I missing?

Debug logs:

2025-07-12T12:48:05.909+02:00 DEBUG 13678 --- [runebound-be] [nio-8080-exec-1] o.s.s.web.DefaultRedirectStrategy        : Redirecting to http://localhost:8080/login;jsessionid=C08750A735CD8755DFED4200540A4D67
2025-07-12T12:48:05.928+02:00 DEBUG 13678 --- [runebound-be] [nio-8080-exec-2] s.s.w.f.HttpStatusRequestRejectedHandler : Rejecting request due to: The request was rejected because the URL contained a potentially malicious String ";"

org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL contained a potentially malicious String ";"
    at

I added the following config to resolve the issue:

@Bean
    fun webSecurityCustomizer(): WebSecurityCustomizer {
        val firewall = StrictHttpFirewall()
        firewall.setAllowBackSlash(true)
        firewall.setAllowSemicolon(true)
        return WebSecurityCustomizer { web: WebSecurity? -> web!!.httpFirewall(firewall) }
    }

Now the auth passes and I see user details in my logs but the OidcUser endpoint still returns null for Discord (works for Google). In debug I see SecurityContext.principal is correctly filled, but the @AuthenticationPrincipal oidcUser attribute is null.


Solution

  • I discovered that Discord and Google probably uses different Oauth2 flow and thus Spring serializes the principal into different classes with common ancestor. I started using the common class in my controllers and it works great:

    @QueryMapping
    fun userInfo(@AuthenticationPrincipal principal: OAuth2AuthenticatedPrincipal?): User? {
            return if (principal == null) {
                null
            } else {
                return permissionService.getLoggedInUser(principal)
            }
    }