springkotlinazure-active-directoryspring-webclientspring-reactive

Spring WebClient POST to login.microsoftonline.com gives 404 error


I'm having a problem implementing Oauth2 with Azure AD using Spring Reactive WebClient (org.springframework.web.reactive.function.client.WebClient) in Kotlin, following this documentation: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#first-case-access-token-request-with-a-shared-secret

My organization has its own tenant, but the problem also applies when using https://login.microsoftonline.com/common/oauth2/v2.0/token.

If I do the following CURL, I get the expected 400 error that I need the parameter grant_type:

curl -d '' https://login.microsoftonline.com/common/oauth2/v2.0/token

Which is fine, it means I get a proper error from the service, and I can work on including the missing parameters. However, using WebClient towards the same uri I get a 404 error.

This is my implementation:

import com.fasterxml.jackson.annotation.JsonProperty
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient


@Component
class AzureADClient {

    fun oidcToken(): String {
        var received = WebClient.create("https://login.microsoftonline.com/common/oauth2/v2.0/token").post()
            .retrieve()
            .bodyToMono(OidcToken::class.java)
            .block()

        return received.token
    }

    data class OidcToken(
        @JsonProperty(value = "access_token", required = true)
        val token: String,
        @JsonProperty(value = "token_type", required = true)
        val type: String,
        @JsonProperty(value = "expires_in", required = true)
        val expiresIn: Int
    )
}

And this is the stack trace I get:

org.springframework.web.reactive.function.client.WebClientResponseException$NotFound: 404 Not Found from POST https://login.microsoftonline.com/common/oauth2/v2.0/token
    at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:185)
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    |_ checkpoint ⇢ 404 from POST https://login.microsoftonline.com/common/oauth2/v2.0/token [DefaultWebClient]
Stack trace:
        at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:185)
        at org.springframework.web.reactive.function.client.DefaultClientResponse.lambda$createException$1(DefaultClientResponse.java:209)
        at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:100)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onNext(ScopePassingSpanSubscriber.java:90)
        at reactor.core.publisher.Operators$MonoSubscriber.complete(Operators.java:1712)
        at reactor.core.publisher.FluxDefaultIfEmpty$DefaultIfEmptySubscriber.onComplete(FluxDefaultIfEmpty.java:100)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:104)
        at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onComplete(FluxMapFuseable.java:144)
        at reactor.core.publisher.FluxContextStart$ContextStartSubscriber.onComplete(FluxContextStart.java:122)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:104)
        at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onComplete(FluxMapFuseable.java:144)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:104)
        at reactor.core.publisher.FluxFilterFuseable$FilterFuseableSubscriber.onComplete(FluxFilterFuseable.java:165)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:104)
        at reactor.core.publisher.Operators$MonoSubscriber.complete(Operators.java:1713)
        at reactor.core.publisher.MonoCollect$CollectSubscriber.onComplete(MonoCollect.java:160)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:104)
        at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:136)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:104)
        at reactor.core.publisher.FluxPeek$PeekSubscriber.onComplete(FluxPeek.java:252)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:104)
        at reactor.core.publisher.FluxPeek$PeekSubscriber.onComplete(FluxPeek.java:252)
        at org.springframework.cloud.sleuth.instrument.reactor.ScopePassingSpanSubscriber.onComplete(ScopePassingSpanSubscriber.java:104)
        at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:136)
        at reactor.netty.channel.FluxReceive.terminateReceiver(FluxReceive.java:421)
        at reactor.netty.channel.FluxReceive.drainReceiver(FluxReceive.java:211)
        at reactor.netty.channel.FluxReceive.onInboundComplete(FluxReceive.java:369)
        at reactor.netty.channel.ChannelOperations.onInboundComplete(ChannelOperations.java:367)
        at reactor.netty.channel.ChannelOperations.terminate(ChannelOperations.java:416)
        at reactor.netty.http.client.HttpClientOperations.onInboundNext(HttpClientOperations.java:612)
        at reactor.netty.channel.ChannelOperationsHandler.channelRead(ChannelOperationsHandler.java:90)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.handler.codec.MessageToMessageDecoder.channelRead(MessageToMessageDecoder.java:102)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:436)
        at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:321)
        at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:295)
        at io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:251)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.handler.ssl.SslHandler.unwrap(SslHandler.java:1486)
        at io.netty.handler.ssl.SslHandler.decodeJdkCompatible(SslHandler.java:1235)
        at io.netty.handler.ssl.SslHandler.decode(SslHandler.java:1282)
        at io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:498)
        at io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:437)
        at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:276)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
        at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
        at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919)
        at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:163)
        at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:714)
        at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:650)
        at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:576)
        at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:493)
        at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
        at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        at java.base/java.lang.Thread.run(Thread.java:834)

I'm using WebClient with POST requests in other interactions just fine. If I replace the WebClient call with a RestTemplate().postForObject() I get the expected 400 error, but I'm trying to use WebClient all the way in my application. I tried doing a similar POST to https://postman-echo.com/post, and there I got a reasonable response, so it seems specific to the login.microsoftonline.com end point.

Has anyone encountered this, and found a solution?


Solution

  • I found a solution. First of all, the reply on this post suggests that the host doesn't accept chunked transfer encoding: https://social.msdn.microsoft.com/Forums/en-US/8efc2e39-8e9c-4449-8e25-13b97a7acf15/azure-ad-oauth-20-token-endpoint-recently-started-returning-404-with-empty-response-body-but-used?forum=WindowsAzureAD

    This is verified by curl:

    > curl -H "Transfer-Encoding: chunked" -d ''  https://login.microsoftonline.com/common/oauth2/v2.0/token -v
    < ...
    < HTTP/1.1 404 Not Found
    < ...
    

    I first tried expanding my request to include the required parameters, like this:

        fun oidcToken(): String {
    
            val map = LinkedMultiValueMap<String, String>()
    
            map.add("client_id", "client_id")
            map.add("client_secret", "client_secret")
            map.add("grant_type", "grant_type")
            map.add("scope", "api://scope_id/.default")
    
            val received = WebClient
                .create("https://login.microsoftonline.com/common/oauth2/v2.0/token")
                .post()
                .body(BodyInserters.fromMultipartData(map))
                .retrieve()
                .bodyToMono(OidcToken::class.java)
                .block()
    
            return received.token
        }
    

    This gave the same 404 error as before, so the transfer is probably chunked here as well.

    The solution turned out to be using .bodyValue(map) instead of .body(BodyInserters.fromMultipartData(map)).

    I tried setting the content-length header, following this answer: https://stackoverflow.com/a/51049535/9658830. I did get a 400 error using this method, so the server was found, but bodyValue() turned out to be an easier solution.