spring-projects / spring-projects/spring-security
Spring Security IPv6 issue - is there a global config setting?
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 9.6k
- Forks
- 6.3k
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 52
Description
Ok, after 2 days of trouble shooting, I've narrowed this down to being a Spring Security issue.
Goal:
To run secure login page, via a Spring BFF and Spring Rest API, on AWS (IPV6 stack)
Why IPV6 and not IPV4 - (i) it's a much more modern protocol, and (ii) avoids the $600+ annual cost of a NAT Gateway (for Ipv4)
Started in: July 2024 (I know it's been 6 months)
Environment
AWS
ECS - Fargate
Essentially running Docker Images - 2 of them, one for each App
The containers run in an AWS Private Subnet, wrapped around an AWS Security Groups (for better security)
I have an Ipv6 egress only gateway on the route tables in the private subnet (so Ipv6 calls can get out into the internet)
Observations:
Initially my Rest API was unable get an Auth0 public key, as it was timing out.
I found out that this was because my code was resolving to Ipv4, despite me configuring some settings in Java / Spring to prefer Ipv6.
In the end I had to write this code, to get it to work
Note that when calling the webclient I do use a blocking call (not recommended for Reactive non-blocking Spring Webflux), but this seems to work for me, and it seemed ok to me since I'm getting the key from Auth0 on an infrequent refresh cycle.
JwtConfig class - in Spring Rest API
/**
* Configuration class for JWT handling with IPv6 support.
* Provides JWT decoder configuration with custom IPv6 resolver and caching mechanisms.
*
* @property serverProperties Server properties containing Auth0 JWK set URI configuration
*/
@Configuration
internal class JwtConfig(
private val serverProperties: ServerProperties,
) {
companion object {
private val logger = LoggerFactory.getLogger(JwtConfig::class.java)
}
/**
* Custom implementation of JWKSetSource for retrieving and caching JSON Web Key Sets.
*
* @property webClient WebClient configured for IPv6 connections
* @property jwkSetUri URI endpoint for retrieving the JWK set
*/
private inner class CustomJwkSetSource(
private val webClient: WebClient,
private val jwkSetUri: URI
) : JWKSetSource<SecurityContext> {
private var cachedJwkSet: JWKSet? = null
/**
* Retrieves the JWK set from the configured endpoint with caching support.
* Attempts to fetch a fresh JWK set and falls back to cached version if available.
*
* @param refreshEvaluator Optional evaluator for cache refresh decisions
* @param currentTime Current timestamp for cache evaluation
* @param context Optional security context for the retrieval operation
* @return JWKSet containing the retrieved or cached key set
* @throws JwkSetRetrievalException if retrieval fails and no cached version is available
*/
override fun getJWKSet(
refreshEvaluator: JWKSetCacheRefreshEvaluator?,
currentTime: Long,
context: SecurityContext?
): JWKSet {
try {
val response = webClient.get()
.uri(jwkSetUri)
.retrieve()
.toEntity(String::class.java)
.block(Duration.ofSeconds(30))
?: return handleJwkSetError("Failed to retrieve JWK set - null response")
val body = response.body
?: return handleJwkSetError("Empty JWK set response")
return try {
JWKSet.parse(body).also {
cachedJwkSet = it
logger.info("Successfully retrieved and cached new JWK set")
}
} catch (e: Exception) {
logger.error("Failed to parse JWK set response", e)
handleJwkSetError("Invalid JWK set format: ${e.message}")
}
} catch (ex: Exception) {
logger.error("Error retrieving JWK set", ex)
return handleJwkSetError("Failed to retrieve JWK set: ${ex.message}")
}
}
/**
* Handles JWK set retrieval errors by attempting to use cached data.
* Logs error details and attempts to provide a cached version if available.
*
* @param errorMessage Detailed description of the error that occurred
* @return Cached JWKSet if available
* @throws JwkSetRetrievalException if no cached data is available
*/
private fun handleJwkSetError(errorMessage: String): JWKSet {
cachedJwkSet?.let {
logger.error("$errorMessage. Falling back to cached version")
return it
}
logger.error("$errorMessage. No cached version available")
throw JwkSetRetrievalException(errorMessage)
}
override fun close() {
// No resources to clean up
}
}
/**
* Exception thrown when JWK set retrieval fails and no cached version is available.
*
* @param message Detailed error message describing the failure
*/
private class JwkSetRetrievalException(message: String) : RuntimeException(message)
/**
* Creates and configures a ReactiveJwtDecoder bean with IPv6 support.
* Configures the decoder with:
* - Custom IPv6 resolver
* - JWK set caching
* - Rate limiting
* - Error handling
*
* @return Configured ReactiveJwtDecoder instance
*/
@Bean
fun jwtDecoder(): ReactiveJwtDecoder {
val jwkSetUri = URI(serverProperties.auth0JwKeySetUri)
logger.info("Initializing JWT decoder for jwkSetUri: $jwkSetUri")
val jwkSource = JWKSourceBuilder.create(
CustomJwkSetSource(createIpv6WebClient(jwkSetUri), jwkSetUri)
)
.cache(
TimeUnit.HOURS.toMillis(12),
TimeUnit.MINUTES.toMillis(60)
)
.rateLimited(TimeUnit.MINUTES.toMillis(10)) { event ->
logger.warn("Rate limit reached for JWK source")
}
.refreshAheadCache(true)
.build()
val jwtProcessor = DefaultJWTProcessor<SecurityContext>().apply {
setJWSKeySelector(JWSAlgorithmFamilyJWSKeySelector.fromJWKSource(jwkSource))
}
return NimbusReactiveJwtDecoder { jwt ->
Mono.fromCallable {
try {
jwtProcessor.process(jwt, null)
} catch (ex: Exception) {
logger.error("JWT processing error", ex)
throw ex
}
}
}
}
}
IPV6 Utility class -
For creating the IPV6 web-builder
The most important thing for me to see here is the Resource address and whether that is an IPV6 address:
And in my logs I see this, and when I run this on AWS, all works as expected the Auth0 JwT key is retrieved
2024-12-23T09:00:35.748Z DEBUG 1 --- [Timesheets-RESTApiApplication] [tor-tcp-epoll-2] c.v.t.auth.ipv6.Ipv6WebClientUtil : Remote Address: dev-ld4xuyx1eigiqoge.uk.auth0.com/[2606:4700:4400:0:0:0:6812:2346]:443
2024-12-23T09:00:36.202860361Z 2024-12-23T09:00:36.201Z INFO 1 --- [Timesheets-RESTApiApplication] [ main] c.v.t.auth.tokens.jwt.JwtConfig : Successfully retrieved and cached new JWK set
/**
* Utility object for IPv6-enabled WebClient creation and DNS resolution.
* Provides reusable components for IPv6 network connectivity with IPv4 fallback.
*/
internal object Ipv6WebClientUtil {
private val logger = LoggerFactory.getLogger(Ipv6WebClientUtil::class.java)
/**
* Initialize IPv6 preferences when the object is first accessed.
* These are JVM-wide settings that should be set once at startup.
*/
init {
initializeIpv6Preferences()
}
/**
* Initializes IPv6 preferences for the JVM and logs current settings.
* This should only be called once during application startup.
*/
private fun initializeIpv6Preferences() {
// Set IPv6 preferences
System.setProperty("java.net.preferIPv6Addresses", "true")
System.setProperty("java.net.preferIPv6Stack", "true")
java.security.Security.setProperty("networkaddress.preferIPv6Addresses", "true")
// Log the current settings
logger.debug("IPv6 Preferences:")
logger.debug("preferIPv6Addresses: ${System.getProperty("java.net.preferIPv6Addresses")}")
logger.debug("preferIPv6Stack: ${System.getProperty("java.net.preferIPv6Stack")}")
logger.debug("Security preferIPv6Addresses: ${java.security.Security.getProperty("networkaddress.preferIPv6Addresses")}")
}
/**
* Creates a custom IPv6 address resolver for Netty.
* Handles both single and multiple address resolution with IPv6 preference.
* Falls back to IPv4 if IPv6 is not available
*
* @param host The hostname to resolve
* @return AddressResolverGroup configured for IPv6 resolution
*/
fun createIpv6Resolver(host: String): AddressResolverGroup<InetSocketAddress> {
return object : AddressResolverGroup<InetSocketAddress>() {
override fun newResolver(executor: EventExecutor) =
object : AbstractAddressResolver<InetSocketAddress>(executor) {
override fun doResolve(address: InetSocketAddress?, promise: Promise<InetSocketAddress>?) {
try {
val hostname = address?.hostName ?: host
val port = address?.port ?: 443
logger.debug("Attempting to resolve IPv6 address for: $hostname:$port")
// First attempt to resolve IPv6 address
val resolvedAddress = InetAddress.getAllByName(hostname)
.firstOrNull { it is Inet6Address }
?.let {
logger.info("Resolved IPv6: ${it.hostAddress}")
InetSocketAddress(it, port)
}
?: run {
// Fallback to IPv4 if no IPv6 address is available
InetAddress.getAllByName(hostname)
.firstOrNull()
?.let {
logger.info("Resolved IPv4: ${it.hostAddress}")
InetSocketAddress(it, port)
}
}
?: throw IllegalStateException("No address available for $hostname")
logger.debug("Resolved address: ${resolvedAddress.address.hostAddress}:$port")
promise?.setSuccess(resolvedAddress)
} catch (ex: Exception) {
logger.error("Failed to resolve IPv6 address", ex)
promise?.setFailure(ex)
}
}
override fun doResolveAll(address: InetSocketAddress?, promise: Promise<MutableList<InetSocketAddress>>?) {
try {
val hostname = address?.hostName ?: host
val port = address?.port ?: 443
// First resolve IPv6 addresses, then fallback to IPv4
val addresses = InetAddress.getAllByName(hostname)
.filterIsInstance<Inet6Address>()
.map { InetSocketAddress(it, port) }
.ifEmpty {
InetAddress.getAllByName(hostname)
.map { InetSocketAddress(it, port) }
}
if (addresses.isEmpty()) {
promise?.setFailure(IllegalStateException("No addresses available for $hostname"))
} else {
promise?.setSuccess(addresses.toMutableList())
}
} catch (e: Exception) {
logger.error("Failed to resolve addresses", e)
promise?.setFailure(e)
}
}
override fun doIsResolved(address: InetSocketAddress?) =
address?.address != null && !address.isUnresolved
}
}
}
/**
* Creates an IPv6-enabled WebClient for token endpoint communications.
*
* Features:
* - IPv6 address resolution with IPv4 fallback
* - Custom timeout configuration
* - Secure HTTPS support
* - Connection logging
*
* @param host The token endpoint hostname to resolve
* @return WebClient configured with IPv6 support
*/
fun createIpv6WebClient(uri: URI): WebClient {
return WebClient.builder()
.baseUrl(uri.toString()) // Use the full URI as base URL
.clientConnector(
ReactorClientHttpConnector(
HttpClient.from(
TcpClient.create()
.resolver(createIpv6Resolver(uri.host)) // Resolver uses only the host
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 30000)
.doOnConnected { connection ->
val remoteAddress = connection.channel().remoteAddress()
logger.debug("Remote Address: {}", remoteAddress)
}
).secure()
)
)
.build()
}
}
THE PROBLEM
Now the problem is with my Spring BFF
In my OauthAuthorizedManagerConfig class, I have the following code. I included the extra bean tokenResponseClient, to see if I could see and also set the webclient when the Spring BFF - Auth0 token exchange is done (to force preference to IPV6)
Here, I use the same Ipv6 Util class as I did in my Spring Rest API, with all the Spring / Java settings to prefer Ipv6.
The specific call to the ipv6 util webclient is here:
return WebClientReactiveAuthorizationCodeTokenResponseClient().apply {
setWebClient(createIpv6WebClient(jwkSetUri)
And the Spring BFF class itself...
OauthAuthorizedManagerConfig class
@Configuration
internal class OAuth2AuthorizedManagerConfig(
private val serverProperties: ServerProperties
) {
companion object {
private val logger = LoggerFactory.getLogger(OAuth2AuthorizedManagerConfig::class.java)
}
/**
* Creates and configures the ReactiveOAuth2AuthorizedClientManager.
*
* Manager responsibilities:
* - Coordinates client registration access
* - Manages token lifecycle
* - Handles authorization persistence
* - Provides reactive processing
*
* Security measures:
* - Secure component integration
* - Token handling procedures
* - State validation
* - Operation logging
*
* @param reactiveClientRegistrationRepository Source of OAuth2 client registrations
* @param redisServerOAuth2AuthorizedClientRepository Persistence for authorized clients
* @param reactiveAuthorizedClientProvider Token lifecycle manager
* @return Configured [ReactiveOAuth2AuthorizedClientManager]
*/
@Bean
fun reactiveAuthorizedClientManager(
reactiveClientRegistrationRepository: ReactiveClientRegistrationRepository,
redisServerOAuth2AuthorizedClientRepository: RedisServerOAuth2AuthorizedClientRepository,
reactiveAuthorizedClientProvider: ReactiveOAuth2AuthorizedClientProvider,
authoritiesConverter: AuthoritiesConverterClaimSet,
): ReactiveOAuth2AuthorizedClientManager = DefaultReactiveOAuth2AuthorizedClientManager(
reactiveClientRegistrationRepository,
redisServerOAuth2AuthorizedClientRepository
).apply {
logger.debug("Configuring OAuth2 authorized client manager")
setAuthorizedClientProvider(reactiveAuthorizedClientProvider)
}
@Bean
fun tokenResponseClient(): ReactiveOAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
val auth0TokenUri = URI(serverProperties.auth0TokenUri)
val jwkSetUri = URI(serverProperties.auth0JwKeySetUri)
logger.info("Initializing Auth0 token for auth0TokenUri: $auth0TokenUri")
return WebClientReactiveAuthorizationCodeTokenResponseClient().apply {
setWebClient(createIpv6WebClient(jwkSetUri)
.mutate()
.filter { request, next ->
logger.debug("""
Token Exchange Request:
----------------------------------------
URI: ${request.url()}
Method: ${request.method()}
Headers: ${
request.headers().map { (key, value) ->
"$key: ${value.joinToString(", ")}"
}.joinToString("\n")
}
Request Time: ${LocalDateTime.now()}
Grant Type: ${request.headers().contentType}
Request URI: ${request.url()}
""".trimIndent())
next.exchange(request)
.doOnNext { response ->
logger.debug("""
Token Exchange Response:
----------------------------------------
Status: ${response.statusCode()}
Response Time: ${LocalDateTime.now()}
Headers (Full):
${
response.headers().asHttpHeaders()
.entries
.joinToString("\n") { (key, value) ->
" $key: ${value.joinToString(", ")}"
}
}
""".trimIndent())
}
.doOnError { error ->
logger.error("""
Token Exchange Error:
----------------------------------------
Error Type: ${error.javaClass.simpleName}
Message: ${error.message}
Stack Trace:
${error.stackTraceToString()}
""".trimIndent())
}
}
.clientConnector(ReactorClientHttpConnector(
HttpClient.create().resolver(DefaultAddressResolverGroup.INSTANCE)
.doOnConnected { connection ->
val remoteAddress = connection.channel().remoteAddress()
logger.debug("Remote Address: {}", remoteAddress)
}
))
.build()
)
}
}
}
Internally when the webclient is set and called by WebClientReactiveAuthorizationCodeTokenResponseClient,
return WebClientReactiveAuthorizationCodeTokenResponseClient().apply {
setWebClient(createIpv6WebClient(jwkSetUri)
It does something like this (it doesn't use the block command, probably a red herring, but the only difference I noted to my Spring Rest API code)
private RequestHeadersSpec<?> populateRequest(T grantRequest) {
return this.webClient.post()
.uri(clientRegistration(grantRequest).getProviderDetails().getTokenUri())
.headers((headers) -> {
HttpHeaders headersToAdd = getHeadersConverter().convert(grantRequest);
if (headersToAdd != null) {
headers.addAll(headersToAdd);
}
})
.body(createTokenRequestBody(grantRequest));
}
But importantly, for some reason, the resolved address always ends up being an Ipv4 one (when using val jwkSetUri = URI(serverProperties.auth0JwKeySetUri) or val auth0TokenUri = URI(serverProperties.auth0TokenUri) as the input URL)
2024-12-22T20:23:22.774Z DEBUG 1 --- [BFFApplication] [or-http-epoll-2] c.v.b.a.m.OAuth2AuthorizedManagerConfig : Remote Address: dev-ld4xuyx1eigiqoge.uk.auth0.com/172.64.152.186:443
And this is the root cause of the issue...
The request won't pass out of the AWS Environment (unless I add a NAT Gateway, to allow outbound Ipv4 requests from a Private Subnet)
Not an Auth0 issue
If I take the JwtConfig class, from my Spring Rest API and place a copy of it in my Spring BFF container, that still resolves to IPV6. (when using val jwkSetUri = URI(serverProperties.auth0JwKeySetUri) as the input URI to createIpv6WebClient() ) - so that rules this out as being an Auth0 issue.
It is more an issue with the tokenResponseClient class, and the way the webClient is being called, which I cannot pin down.
I imagine there might be a few webclients all over the Spring BFF, which call auth0 endpoints here and there (see below), apart from the tokenResponseClient bean. But for some reason, at least the tokenResponseClient bean above it is not resolving to IPV6 - which is causing problems.
Is anyone from the Spring Security team able to provide guidance or help? Maybe they can see an issue with my configuration or code?
Many thanks in advance
Spring BFF Auth0 endpoints
Endpoints being called that may potentially be calling webclients supporting / not preferring IPV6, unless here is a global configuration for this, that needs to be put into a particular class.
private fun auth0Registration(): ClientRegistration {
return ClientRegistration
.withRegistrationId(serverProperties.auth0AuthRegistrationId)
.clientId(clientSecurityProperties.auth0ClientId)
.clientSecret(clientSecurityProperties.auth0ClientSecret)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("${serverProperties.clientUri}/login/oauth2/code/${serverProperties.auth0AuthRegistrationId}")
.authorizationUri("${serverProperties.auth0IssuerUri}/authorize")
.tokenUri("${serverProperties.auth0IssuerUri}/oauth/token")
.jwkSetUri("${serverProperties.auth0IssuerUri}/.well-known/jwks.json")
.userInfoUri("${serverProperties.auth0IssuerUri}/userinfo")
.providerConfigurationMetadata(mapOf(
"issuer" to "${serverProperties.auth0IssuerUri}/",
"authorization_endpoint" to "${serverProperties.auth0IssuerUri}/authorize",
"token_endpoint" to "${serverProperties.auth0IssuerUri}/oauth/token",
"userinfo_endpoint" to "${serverProperties.auth0IssuerUri}/userinfo",
"end_session_endpoint" to "${serverProperties.auth0IssuerUri}/v2/logout",
"jwks_uri" to "${serverProperties.auth0IssuerUri}/.well-known/jwks.json",
"revocation_endpoint" to "${serverProperties.auth0IssuerUri}/oauth/revoke"
))
.userNameAttributeName(IdTokenClaimNames.SUB)
.scope("openid", "offline_access")
.clientName("BFF-Server-Auth-0")
.issuerUri("${serverProperties.auth0IssuerUri}/")
.build()
}
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the reported JwtConfig and Ipv6WebClientUtil entry points, then trace how Spring Security retrieves the Auth0 JWK set through the WebClient and Netty resolver. Reproduce the IPv4-versus-IPv6 behavior in the AWS ECS/Fargate environment and determine whether a global Spring Security setting is expected; done means documenting or confirming the supported configuration and behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- aws, docker, java, spring
- Domain
- authentication, cloud, networking, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100