diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/BearerTokenReactiveAuthenticationManagerResolver.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/BearerTokenReactiveAuthenticationManagerResolver.kt index d627961..467bb9e 100644 --- a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/BearerTokenReactiveAuthenticationManagerResolver.kt +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/BearerTokenReactiveAuthenticationManagerResolver.kt @@ -41,8 +41,14 @@ class BearerTokenReactiveAuthenticationManagerResolver( private val client: AuthenticationStoreClient, private val auditClient: AuthenticationAuditClient, ) : ReactiveAuthenticationManagerResolver { - override fun resolve(exchange: ServerWebExchange): Mono = - Mono.just(exchange).map { + override fun resolve(exchange: ServerWebExchange): Mono { + val callContextHeader = exchange.request.headers.getFirst("gd-call-context") + if (callContextHeader != null && callContextHeader.contains("\"authMethod\":\"JWT\"")) { + // Gateway has already validated this JWT via call context — skip re-validation + return Mono.just(ReactiveAuthenticationManager { Mono.empty() }) + } + + return Mono.just(exchange).map { val sourceIp = exchange.request.remoteAddress?.address?.hostAddress ?: exchange.request.remoteAddress?.hostName @@ -51,6 +57,7 @@ class BearerTokenReactiveAuthenticationManagerResolver( PersistentApiTokenAuthenticationManager(client, auditClient, sourceIp) ) } + } } /** diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationProcessor.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationProcessor.kt index d4606cd..c27346d 100644 --- a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationProcessor.kt +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationProcessor.kt @@ -87,7 +87,7 @@ class CallContextAuthenticationProcessor( val userContextData = UserContextData( organizationId = authDetails.organizationId, userId = authDetails.userId, - userName = null, + userName = authDetails.userName, tokenId = authDetails.tokenId, authMethod = authMethod, accessToken = null diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt index f03dcf5..c812222 100644 --- a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt @@ -54,5 +54,6 @@ data class CallContextAuth( val organizationId: String, val userId: String, val authMethod: String, - val tokenId: String? = null + val tokenId: String? = null, + val userName: String? = null, ) diff --git a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/BearerTokenReactiveAuthenticationManagerResolverTest.kt b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/BearerTokenReactiveAuthenticationManagerResolverTest.kt index 2623efe..3525a9d 100644 --- a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/BearerTokenReactiveAuthenticationManagerResolverTest.kt +++ b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/BearerTokenReactiveAuthenticationManagerResolverTest.kt @@ -52,6 +52,7 @@ internal class BearerTokenReactiveAuthenticationManagerResolverTest { private val exchange: ServerWebExchange = mockk { every { request.uri.host } returns HOST every { request.remoteAddress } returns InetSocketAddress("127.0.0.1", 8080) + every { request.headers.getFirst("gd-call-context") } returns null } @Test @@ -59,6 +60,7 @@ internal class BearerTokenReactiveAuthenticationManagerResolverTest { val mockExchange: ServerWebExchange = mockk { every { request.uri.host } returns HOST every { request.remoteAddress } returns InetSocketAddress("127.0.0.1", 8080) + every { request.headers.getFirst("gd-call-context") } returns null } val resolver = BearerTokenReactiveAuthenticationManagerResolver(client, auditClient) diff --git a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/BearerTokenResolverJwtCallContextTest.kt b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/BearerTokenResolverJwtCallContextTest.kt new file mode 100644 index 0000000..4935b58 --- /dev/null +++ b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/BearerTokenResolverJwtCallContextTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2025 GoodData Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gooddata.oauth2.server + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken +import org.springframework.web.server.ServerWebExchange +import strikt.api.expectThat +import strikt.assertions.isNull +import strikt.assertions.isNotNull +import java.net.InetSocketAddress + +internal class BearerTokenResolverJwtCallContextTest { + + private val client: AuthenticationStoreClient = mockk() + private val auditClient: AuthenticationAuditClient = mockk() + + @Test + fun `resolve returns no-op manager when call context has authMethod=JWT`() { + val mockExchange: ServerWebExchange = mockk { + every { request.headers.getFirst("gd-call-context") } returns + """{"authMethod":"JWT","userId":"u1","orgId":"org1"}""" + every { request.remoteAddress } returns InetSocketAddress("127.0.0.1", 8080) + } + + val resolver = BearerTokenReactiveAuthenticationManagerResolver(client, auditClient) + val manager = resolver.resolve(mockExchange).block()!! + + val token = BearerTokenAuthenticationToken("some.jwt.token") + val result = manager.authenticate(token).block() + + expectThat(result).isNull() + } + + @Test + fun `resolve returns non-null manager when call context header is absent`() { + val mockExchange: ServerWebExchange = mockk { + every { request.headers.getFirst("gd-call-context") } returns null + every { request.remoteAddress } returns InetSocketAddress("127.0.0.1", 8080) + } + + val resolver = BearerTokenReactiveAuthenticationManagerResolver(client, auditClient) + val manager = resolver.resolve(mockExchange).block() + + expectThat(manager).isNotNull() + } + + @Test + fun `resolve returns non-null manager when call context has different authMethod`() { + val mockExchange: ServerWebExchange = mockk { + every { request.headers.getFirst("gd-call-context") } returns + """{"authMethod":"API_TOKEN","userId":"u1","orgId":"org1"}""" + every { request.remoteAddress } returns InetSocketAddress("127.0.0.1", 8080) + } + + val resolver = BearerTokenReactiveAuthenticationManagerResolver(client, auditClient) + val manager = resolver.resolve(mockExchange).block() + + expectThat(manager).isNotNull() + } +} diff --git a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationProcessorTest.kt b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationProcessorTest.kt index 95706fe..5cd1045 100644 --- a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationProcessorTest.kt +++ b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationProcessorTest.kt @@ -128,6 +128,36 @@ class CallContextAuthenticationProcessorTest { } } + @Test + fun `propagates userName from CallContextAuth to user context`() { + val authToken = CallContextAuthenticationToken("base64-encoded-header") + val authDetails = CallContextAuth( + organizationId = "org123", + userId = "user456", + authMethod = "OIDC", + tokenId = "token789", + userName = "John Smith" + ) + + every { headerProcessor.parseCallContextHeader("base64-encoded-header") } returns authDetails + coEvery { + userContextProvider.getContextView(any(), any(), any(), any(), any(), any()) + } returns Context.empty() + + processor.authenticate(authToken, webExchange, webFilterChain).block() + + coVerify(exactly = 1) { + userContextProvider.getContextView( + organizationId = "org123", + userId = "user456", + userName = "John Smith", + tokenId = "token789", + authMethod = AuthMethod.OIDC, + accessToken = null + ) + } + } + @Test fun `null call context throws exception`() { val authToken = CallContextAuthenticationToken("header-value")