Spring WebClient POST 到 login.microsoftonline.com 给出 404 错误

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

我在 Kotlin 中使用 Spring Reactive WebClient (org.springframework.web.reactive.function.client.WebClient) 通过 Azure AD 实现 Oauth2 时遇到问题,请遵循以下文档:https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#first-case-access-token-request-with-a-shared-secret

我的组织有自己的租户,但使用 https://login.microsoftonline.com/common/oauth2/v2.0/token.

时也会出现此问题

如果我执行以下 CURL,我会收到预期的 400 错误,我需要参数 grant_type:

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

这很好,这意味着我从服务中得到了正确的错误,我可以继续处理缺少的参数。但是,对相同的 uri 使用 WebClient 我收到 404 错误。

这是我的实现:

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
    )
}

这是我得到的堆栈跟踪:

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(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.run(SingleThreadEventExecutor.java:989)
        at io.netty.util.internal.ThreadExecutorMap.run(ThreadExecutorMap.java:74)
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        at java.base/java.lang.Thread.run(Thread.java:834)

我在其他交互中使用带有 POST 请求的 WebClient 就好了。如果我用 RestTemplate().postForObject() 替换 WebClient 调用,我会收到预期的 400 错误,但我一直在尝试在我的应用程序中使用 WebClient。我尝试对 https://postman-echo.com/post 进行类似的 POST,并且得到了合理的响应,因此它似乎特定于 login.microsoftonline.com 端点。

有没有人遇到过这个,并找到了解决办法?

我找到了解决办法。首先,这个post的回复表明主机不接受分块传输编码: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

这是通过 curl 验证的:

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

我首先尝试扩展我的请求以包含所需的参数,如下所示:

    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
    }

这给出了与以前相同的 404 错误,因此传输也可能在这里分块。

解决方案原来是使用 .bodyValue(map) 而不是 .body(BodyInserters.fromMultipartData(map))

我尝试设置 content-length header,遵循这个答案:https://whosebug.com/a/51049535/9658830。我确实使用此方法遇到了 400 错误,因此找到了服务器,但 bodyValue() 结果是一个更简单的解决方案。