From 608486d5345ed439d8bc4a2ce41efa9376e1fd18 Mon Sep 17 00:00:00 2001 From: Christopher Bonilla Date: Wed, 8 Oct 2025 08:56:14 +0200 Subject: [PATCH] feat: CallContext support for service-to-service In addition to OIDC/OAuth2 and Bearer token authentication, the library now supports **call context authentication** for service-to-service communication. This mechanism is designed for scenarios where authentication has already been performed by an upstream service (e.g., an API gateway). Key characteristics: * Authentication information is passed via a custom HTTP header (configurable by the application) * No re-authentication or metadata lookups are performed (trusts upstream validation) * Takes precedence over Bearer token authentication when the call context header is present * Requires implementing [CallContextHeaderProcessor](gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt) define the custom header name via `getHeaderName()` parse the application-specific header format via `parseCallContextHeader()` #### Security Considerations **Critical**: This authentication mode bypasses normal security checks and trusts the upstream service completely. Implementations must ensure: * **Gateway-level protection**: The API gateway **must** strip/discard the call context header from all external requests. This is the primary security control. * **Network segmentation**: Services using this feature should not be directly accessible from untrusted networks. Deploy behind a properly configured API gateway or service mesh. * **Audit logging**: All call context authentications should be logged for security monitoring and incident response. JIRA: LX-1581 risk: high --- README.md | 26 +++ .../CallContextAuthenticationProcessor.kt | 132 +++++++++++++ .../kotlin/CallContextAuthenticationToken.kt | 43 ++++ .../CallContextAuthenticationWebFilter.kt | 92 +++++++++ .../main/kotlin/CallContextHeaderProcessor.kt | 58 ++++++ .../kotlin/ServerOAuth2AutoConfiguration.kt | 33 +++- .../src/main/kotlin/UserContextWebFilter.kt | 14 +- .../CallContextAuthenticationProcessorTest.kt | 185 ++++++++++++++++++ .../CallContextAuthenticationWebFilterTest.kt | 138 +++++++++++++ .../test/kotlin/UserContextWebFilterTest.kt | 60 +++++- 10 files changed, 777 insertions(+), 4 deletions(-) create mode 100644 gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationProcessor.kt create mode 100644 gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationToken.kt create mode 100644 gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationWebFilter.kt create mode 100644 gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt create mode 100644 gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationProcessorTest.kt create mode 100644 gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationWebFilterTest.kt diff --git a/README.md b/README.md index af4e890..92f0562 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,32 @@ root@a45628275f4a:/# ./tinkey create-keyset --key-template AES256_GCM } ``` +### Call Context Authentication + +In addition to OIDC/OAuth2 and Bearer token authentication, the library supports **call context authentication** +for service-to-service communication. This mechanism is designed for scenarios where authentication has already +been performed by an upstream service (e.g., an API gateway). + +Key characteristics: +* Authentication information is passed via a custom HTTP header (configurable by the application) +* No re-authentication or metadata lookups are performed (trusts upstream validation) +* Takes precedence over Bearer token authentication when the call context header is present +* Requires implementing [CallContextHeaderProcessor](gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt) + interface to: + - Define the custom header name via `getHeaderName()` + - Parse the application-specific header format via `parseCallContextHeader()` + +#### Security Considerations + +**Critical**: This authentication mode bypasses normal security checks and trusts the upstream service completely. +Implementations must ensure: + +* **Gateway-level protection**: The API gateway **must** strip/discard the call context header from all external + requests. This is the primary security control. +* **Network segmentation**: Services using this feature should not be directly accessible from untrusted networks. + Deploy behind a properly configured API gateway or service mesh. +* **Audit logging**: All call context authentications should be logged for security monitoring and incident response. + ### HTTP endpoints * **any resource** behind authentication diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationProcessor.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationProcessor.kt new file mode 100644 index 0000000..d4606cd --- /dev/null +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationProcessor.kt @@ -0,0 +1,132 @@ +/* + * 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.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +private val logger = KotlinLogging.logger {} + +/** + * Context data needed to create a user context. + */ +private data class UserContextData( + val organizationId: String, + val userId: String, + val userName: String?, + val tokenId: String?, + val authMethod: AuthMethod?, + val accessToken: String? +) + +/** + * Processes [CallContextAuthenticationToken] and creates user context from call context data. + * + * Unlike other authentication processors, this does NOT fetch organization/user from the authentication store + * because call context authentication represents requests that have already been authenticated by an upstream + * service. The upstream service has already validated credentials, checked for global logout, and verified + * organization/user existence. + * + * This processor delegates header parsing to [CallContextHeaderProcessor] implementation. + */ +class CallContextAuthenticationProcessor( + private val headerProcessor: CallContextHeaderProcessor, + private val userContextProvider: ReactorUserContextProvider +) : AuthenticationProcessor(userContextProvider) { + + override fun authenticate( + authenticationToken: CallContextAuthenticationToken, + exchange: ServerWebExchange, + chain: WebFilterChain + ): Mono { + return try { + val authDetails = headerProcessor.parseCallContextHeader(authenticationToken.callContextHeaderValue) + ?: throw CallContextAuthenticationException("Call context header contains no user information") + + val authMethod = try { + AuthMethod.valueOf(authDetails.authMethod) + } catch (e: IllegalArgumentException) { + logger.logError(e) { + withAction("callContextAuth") + withState("failed") + withMessage { + "Invalid authMethod '${authDetails.authMethod}' in CallContext header. " + + "Valid values: ${AuthMethod.entries.joinToString { it.name }}" + } + } + throw CallContextAuthenticationException( + "Invalid authentication method in call context" + ) + } + + logger.logInfo { + withAction("callContextAuth") + withState("authenticated") + withOrganizationId(authDetails.organizationId) + withUserId(authDetails.userId) + withAuthenticationMethod(authMethod.name) + authDetails.tokenId?.let { withTokenId(it) } + withMessage { "Processed authenticated call context" } + } + + val userContextData = UserContextData( + organizationId = authDetails.organizationId, + userId = authDetails.userId, + userName = null, + tokenId = authDetails.tokenId, + authMethod = authMethod, + accessToken = null + ) + withUserContext(userContextData) { + chain.filter(exchange) + } + } catch (e: CallContextAuthenticationException) { + val remoteAddress = exchange.request.remoteAddress?.address?.hostAddress + logger.logError(e) { + withAction("callContextAuth") + withState("failed") + withMessage { "Call context authentication failed from $remoteAddress" } + } + Mono.error(e) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + // Must catch all exceptions to prevent auth chain disruption + logger.logError(e) { + withAction("callContextAuth") + withState("error") + withMessage { "Unexpected error during call context authentication" } + } + Mono.error(CallContextAuthenticationException("Authentication failed", e)) + } + } + + private fun withUserContext( + userContextData: UserContextData, + monoProvider: () -> Mono + ): Mono { + val contextView = userContextProvider.getContextView( + organizationId = userContextData.organizationId, + userId = userContextData.userId, + userName = userContextData.userName, + tokenId = userContextData.tokenId, + authMethod = userContextData.authMethod, + accessToken = userContextData.accessToken + ) + + return monoProvider().contextWrite(contextView) + } +} diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationToken.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationToken.kt new file mode 100644 index 0000000..40371b0 --- /dev/null +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationToken.kt @@ -0,0 +1,43 @@ +/* + * 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 org.springframework.security.authentication.AbstractAuthenticationToken + +/** + * Authentication token created from call context header. + * + * This represents authentication that has already been validated by an upstream service. + * The call context header can only come from trusted internal services. + * + * @property callContextHeaderValue The raw call context header value + */ +class CallContextAuthenticationToken( + val callContextHeaderValue: String +) : AbstractAuthenticationToken(emptyList()) { + + init { + // Mark as authenticated since upstream service already validated + isAuthenticated = true + } + + override fun getCredentials(): Any? = null + + override fun getPrincipal(): String = callContextHeaderValue + + override fun toString(): String = + "CallContextAuthenticationToken[headerPresent=true]" +} diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationWebFilter.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationWebFilter.kt new file mode 100644 index 0000000..690473e --- /dev/null +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextAuthenticationWebFilter.kt @@ -0,0 +1,92 @@ +/* + * 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.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +private val logger = KotlinLogging.logger {} + +/** + * WebFilter that detects call context header and creates Spring Security authentication. + * + * This filter runs before the main authentication filters and takes precedence over Bearer token + * authentication when the call context header is present AND contains user information. + * The call context header should only come from trusted internal services. + * + * If the header is present with user info, it creates a [CallContextAuthenticationToken] that will be processed + * by [CallContextAuthenticationProcessor]. + * If the header is absent or has no user info, the request continues to other authentication mechanisms. + */ +class CallContextAuthenticationWebFilter( + private val headerProcessor: CallContextHeaderProcessor? +) : WebFilter { + + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + // If no processor configured or no header, skip CallContext authentication + val callContextHeader = headerProcessor?.getHeaderName() + ?.let { exchange.request.headers.getFirst(it) } + + if (callContextHeader == null) { + return chain.filter(exchange) + } + + // Check if the CallContext has user information before creating an authentication token + return try { + val authDetails = headerProcessor?.parseCallContextHeader(callContextHeader) + + // Only proceed with CallContext authentication if we got auth details + if (authDetails != null) { + val remoteHost = exchange.request.remoteAddress?.address?.hostAddress ?: "unknown" + logger.info { + "Call context authentication initiated from $remoteHost" + } + + val authToken = CallContextAuthenticationToken(callContextHeader) + val securityContext = SecurityContextImpl(authToken) + + chain.filter(exchange) + .contextWrite( + ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext)) + ) + } else { + // CallContext has no user info, skip and let the regular authentication chain handle it + chain.filter(exchange) + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + // Must catch all exceptions for graceful fallback to normal auth + val remoteHost = exchange.request.remoteAddress?.address?.hostAddress + logger.warn(e) { + "Failed to parse CallContext header from $remoteHost, " + + "falling back to normal authentication chain" + } + chain.filter(exchange) + } + } +} + +/** + * Exception thrown when CallContext authentication fails. + */ +class CallContextAuthenticationException( + message: String, + cause: Throwable? = null +) : RuntimeException(message, cause) diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt new file mode 100644 index 0000000..f03dcf5 --- /dev/null +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt @@ -0,0 +1,58 @@ +/* + * 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 + +/** + * Interface for processing call context headers from internal service-to-service calls. + * + * Implementations should parse the call context header and extract authentication + * information that has already been validated by an upstream service. + * + * The implementation should be provided by the application using this library. + */ +interface CallContextHeaderProcessor { + + /** + * The name of the HTTP header to use for call context authentication. + * + * @return The HTTP header name (e.g., "X-Custom-Context-Header") + */ + fun getHeaderName(): String + + /** + * Parses the call context header value and returns authentication details. + * + * @param headerValue The header value (typically Base64-encoded) + * @return [CallContextAuth] containing authentication information, or null if the header has no user or + * organization information (which signals that CallContext authentication should be skipped) + */ + fun parseCallContextHeader(headerValue: String): CallContextAuth? +} + +/** + * Authentication details extracted from a call context header. + * + * @property organizationId The organization ID + * @property userId The user ID + * @property authMethod The authentication method (e.g., "API_TOKEN", "OIDC", "JWT") + * @property tokenId The token ID if applicable + */ +data class CallContextAuth( + val organizationId: String, + val userId: String, + val authMethod: String, + val tokenId: String? = null +) diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/ServerOAuth2AutoConfiguration.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/ServerOAuth2AutoConfiguration.kt index 6dc27b5..ae26cf5 100644 --- a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/ServerOAuth2AutoConfiguration.kt +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/ServerOAuth2AutoConfiguration.kt @@ -217,6 +217,29 @@ class ServerOAuth2AutoConfiguration { cookieService: ReactiveCookieService, ) = CustomAttrsAwareOauth2AuthorizationRequestResolver(urlSafeStateAuthorizationRequestResolver, cookieService) + @Bean + @ConditionalOnMissingBean + fun callContextAuthenticationProcessor( + headerProcessor: ObjectProvider, + userContextProvider: ObjectProvider, + ): CallContextAuthenticationProcessor? { + val processor = headerProcessor.ifAvailable + val provider = userContextProvider.ifAvailable + return if (processor != null && provider != null) { + CallContextAuthenticationProcessor(processor, provider) + } else { + null + } + } + + @Bean + @ConditionalOnMissingBean + fun callContextAuthenticationWebFilter( + headerProcessor: ObjectProvider + ): CallContextAuthenticationWebFilter { + return CallContextAuthenticationWebFilter(headerProcessor.ifAvailable) + } + @Bean @Suppress("LongParameterList", "LongMethod") fun springSecurityFilterChain( @@ -235,6 +258,8 @@ class ServerOAuth2AutoConfiguration { jwtDecoderFactory: ObjectProvider>, loginAuthManager: ReactiveAuthenticationManager, federationAwareAuthorizationRequestResolver: ServerOAuth2AuthorizationRequestResolver, + callContextAuthenticationWebFilter: ObjectProvider, + callContextAuthenticationProcessor: ObjectProvider, // TODO these properties serve as a temporary hack. // So for now, we will keep this configuration property here. // Can be moved elsewhere or even removed in the following library release. @@ -333,6 +358,11 @@ class ServerOAuth2AutoConfiguration { requiresLogout = pathMatchers(HttpMethod.GET, "/logout") } addFilterBefore(PostLogoutNotAllowedWebFilter(), SecurityWebFiltersOrder.LOGOUT) + // Add CallContext authentication filter BEFORE other authentication filters + // This ensures CallContext takes precedence when present + callContextAuthenticationWebFilter.ifAvailable?.let { filter -> + addFilterBefore(filter, SecurityWebFiltersOrder.AUTHENTICATION) + } addFilterAfter( UserContextWebFilter( OidcAuthenticationProcessor( @@ -347,7 +377,8 @@ class ServerOAuth2AutoConfiguration { logoutHandler, userContextProvider.`object` ), - UserContextAuthenticationProcessor(userContextProvider.`object`) + UserContextAuthenticationProcessor(userContextProvider.`object`), + callContextAuthenticationProcessor.ifAvailable ), SecurityWebFiltersOrder.LOGOUT ) diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/UserContextWebFilter.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/UserContextWebFilter.kt index aaab77b..59c01c5 100644 --- a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/UserContextWebFilter.kt +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/UserContextWebFilter.kt @@ -46,11 +46,15 @@ import reactor.core.publisher.Mono * * Similarly in case [JwtAuthenticationToken] is used the [JwtAuthenticationProcessor] retrieves [Organization] by * request uri host. And authenticates the user based on `Jwks` configured for the given organization. + * + * In case [CallContextAuthenticationToken] is used the [CallContextAuthenticationProcessor] processes the CallContext + * header that was already validated by an upstream service. */ class UserContextWebFilter( private val oidcAuthenticationProcessor: OidcAuthenticationProcessor, private val jwtAuthenticationProcessor: JwtAuthenticationProcessor, - private val userContextAuthenticationProcessor: UserContextAuthenticationProcessor + private val userContextAuthenticationProcessor: UserContextAuthenticationProcessor, + private val callContextAuthenticationProcessor: CallContextAuthenticationProcessor? ) : WebFilter { private val logger = KotlinLogging.logger {} @@ -81,6 +85,14 @@ class UserContextWebFilter( is OAuth2AuthenticationToken -> oidcAuthenticationProcessor.authenticate(this, exchange, chain) is JwtAuthenticationToken -> jwtAuthenticationProcessor.authenticate(this, exchange, chain) is UserContextAuthenticationToken -> userContextAuthenticationProcessor.authenticate(this, exchange, chain) + is CallContextAuthenticationToken -> { + if (callContextAuthenticationProcessor != null) { + callContextAuthenticationProcessor.authenticate(this, exchange, chain) + } else { + logger.warn { "CallContextAuthenticationToken present but no processor configured" } + chain.filter(exchange) + } + } else -> { logger.warn { "Security context contains unexpected authentication ${this::class}" } chain.filter(exchange) diff --git a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationProcessorTest.kt b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationProcessorTest.kt new file mode 100644 index 0000000..95706fe --- /dev/null +++ b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationProcessorTest.kt @@ -0,0 +1,185 @@ +/* + * 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.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono.empty +import reactor.test.StepVerifier +import reactor.util.context.Context + +class CallContextAuthenticationProcessorTest { + + private val headerProcessor: CallContextHeaderProcessor = mockk() + private val userContextProvider: ReactorUserContextProvider = mockk() + private val processor = CallContextAuthenticationProcessor(headerProcessor, userContextProvider) + + private val webExchange: ServerWebExchange = mockk(relaxed = true) + private val webFilterChain: WebFilterChain = mockk { + every { filter(any()) } returns empty() + } + + @Test + fun `successful authentication with all required fields`() { + val authToken = CallContextAuthenticationToken("base64-encoded-header") + val authDetails = CallContextAuth( + organizationId = "org123", + userId = "user456", + authMethod = "API_TOKEN", + tokenId = "token789" + ) + + 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() + + verify(exactly = 1) { webFilterChain.filter(webExchange) } + coVerify(exactly = 1) { + userContextProvider.getContextView( + organizationId = "org123", + userId = "user456", + userName = null, + tokenId = "token789", + authMethod = AuthMethod.API_TOKEN, + accessToken = null + ) + } + } + + @ParameterizedTest + @ValueSource(strings = ["API_TOKEN", "JWT", "OIDC", "NOT_APPLICABLE"]) + fun `successful authentication with different auth methods`(authMethodStr: String) { + val authToken = CallContextAuthenticationToken("header-value") + val authDetails = CallContextAuth( + organizationId = "org123", + userId = "user456", + authMethod = authMethodStr + ) + + every { headerProcessor.parseCallContextHeader("header-value") } returns authDetails + coEvery { + userContextProvider.getContextView(any(), any(), any(), any(), any(), any()) + } returns Context.empty() + + processor.authenticate(authToken, webExchange, webFilterChain).block() + + verify(exactly = 1) { webFilterChain.filter(webExchange) } + coVerify(exactly = 1) { + userContextProvider.getContextView( + organizationId = "org123", + userId = "user456", + userName = null, + tokenId = null, + authMethod = AuthMethod.valueOf(authMethodStr), + accessToken = null + ) + } + } + + @Test + fun `authentication without optional tokenId`() { + val authToken = CallContextAuthenticationToken("header-value") + val authDetails = CallContextAuth( + organizationId = "org123", + userId = "user456", + authMethod = "JWT" + ) + + every { headerProcessor.parseCallContextHeader("header-value") } 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 = null, + tokenId = null, + authMethod = AuthMethod.JWT, + accessToken = null + ) + } + } + + @Test + fun `null call context throws exception`() { + val authToken = CallContextAuthenticationToken("header-value") + + every { headerProcessor.parseCallContextHeader("header-value") } returns null + + StepVerifier.create(processor.authenticate(authToken, webExchange, webFilterChain)) + .expectErrorMatches { + it is CallContextAuthenticationException && + it.message == "Call context header contains no user information" + } + .verify() + + verify(exactly = 0) { webFilterChain.filter(any()) } + } + + @Test + fun `invalid authMethod enum value throws exception`() { + val authToken = CallContextAuthenticationToken("header-value") + val authDetails = CallContextAuth( + organizationId = "org123", + userId = "user456", + authMethod = "INVALID_METHOD" + ) + + every { headerProcessor.parseCallContextHeader("header-value") } returns authDetails + + StepVerifier.create(processor.authenticate(authToken, webExchange, webFilterChain)) + .expectErrorMatches { + it is CallContextAuthenticationException && + it.message == "Invalid authentication method in call context" + } + .verify() + + verify(exactly = 0) { webFilterChain.filter(any()) } + } + + @Test + fun `malformed header throws exception`() { + val authToken = CallContextAuthenticationToken("malformed-header") + + every { headerProcessor.parseCallContextHeader("malformed-header") } throws + IllegalStateException("Invalid format") + + StepVerifier.create(processor.authenticate(authToken, webExchange, webFilterChain)) + .expectErrorMatches { + it is CallContextAuthenticationException && + it.message == "Authentication failed" && + it.cause is IllegalStateException + } + .verify() + + verify(exactly = 0) { webFilterChain.filter(any()) } + } +} diff --git a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationWebFilterTest.kt b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationWebFilterTest.kt new file mode 100644 index 0000000..2338ef1 --- /dev/null +++ b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/CallContextAuthenticationWebFilterTest.kt @@ -0,0 +1,138 @@ +/* + * 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 io.mockk.verify +import org.junit.jupiter.api.Test +import org.springframework.http.HttpHeaders +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +class CallContextAuthenticationWebFilterTest { + + companion object { + private const val TEST_HEADER_NAME = "X-Test-Context" + } + + private val headerProcessor: CallContextHeaderProcessor = mockk { + every { getHeaderName() } returns TEST_HEADER_NAME + } + private val filter = CallContextAuthenticationWebFilter(headerProcessor) + + private val exchange: ServerWebExchange = mockk(relaxed = true) { + every { request } returns mockk { + every { headers } returns mockk() + every { remoteAddress } returns mockk { + every { address } returns mockk { + every { hostAddress } returns "10.0.0.1" + } + } + } + } + + private val chain: WebFilterChain = mockk { + every { filter(any()) } returns Mono.empty() + } + + @Test + fun `no header present continues to next filter`() { + every { exchange.request.headers.getFirst(TEST_HEADER_NAME) } returns null + + filter.filter(exchange, chain).block() + + verify(exactly = 1) { headerProcessor.getHeaderName() } + verify(exactly = 1) { chain.filter(exchange) } + verify(exactly = 0) { headerProcessor.parseCallContextHeader(any()) } + } + + @Test + fun `no processor configured continues to next filter`() { + val filterWithoutProcessor = CallContextAuthenticationWebFilter(null) + every { exchange.request.headers.getFirst(TEST_HEADER_NAME) } returns "header-value" + + filterWithoutProcessor.filter(exchange, chain).block() + + verify(exactly = 1) { chain.filter(exchange) } + } + + @Test + fun `valid header with all required fields creates authentication token`() { + val headerValue = "valid-header-value" + val authDetails = CallContextAuth( + organizationId = "org123", + userId = "user456", + authMethod = "API_TOKEN", + tokenId = "token789" + ) + + every { exchange.request.headers.getFirst(TEST_HEADER_NAME) } returns headerValue + every { headerProcessor.parseCallContextHeader(headerValue) } returns authDetails + + filter.filter(exchange, chain).block() + + verify(exactly = 1) { headerProcessor.getHeaderName() } + verify(exactly = 1) { headerProcessor.parseCallContextHeader(headerValue) } + verify(exactly = 1) { chain.filter(exchange) } + } + + @Test + fun `null auth details skips CallContext authentication`() { + val headerValue = "incomplete-header" + + every { exchange.request.headers.getFirst(TEST_HEADER_NAME) } returns headerValue + every { headerProcessor.parseCallContextHeader(headerValue) } returns null + + filter.filter(exchange, chain).block() + + verify(exactly = 1) { headerProcessor.parseCallContextHeader(headerValue) } + verify(exactly = 1) { chain.filter(exchange) } + } + + @Test + fun `auth details without tokenId works correctly`() { + val headerValue = "header-without-token" + val authDetails = CallContextAuth( + organizationId = "org123", + userId = "user456", + authMethod = "JWT" + ) + + every { exchange.request.headers.getFirst(TEST_HEADER_NAME) } returns headerValue + every { headerProcessor.parseCallContextHeader(headerValue) } returns authDetails + + filter.filter(exchange, chain).block() + + verify(exactly = 1) { headerProcessor.parseCallContextHeader(headerValue) } + verify(exactly = 1) { chain.filter(exchange) } + } + + @Test + fun `header processor throws exception falls back to normal auth`() { + val headerValue = "malformed-header" + + every { exchange.request.headers.getFirst(TEST_HEADER_NAME) } returns headerValue + every { headerProcessor.parseCallContextHeader(headerValue) } throws + IllegalStateException("Invalid format") + + filter.filter(exchange, chain).block() + + verify(exactly = 1) { headerProcessor.parseCallContextHeader(headerValue) } + verify(exactly = 1) { chain.filter(exchange) } + } +} diff --git a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/UserContextWebFilterTest.kt b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/UserContextWebFilterTest.kt index 447121a..c6f802b 100644 --- a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/UserContextWebFilterTest.kt +++ b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/UserContextWebFilterTest.kt @@ -31,10 +31,12 @@ internal class UserContextWebFilterTest { private val oidcAuthenticationProcessor: OidcAuthenticationProcessor = mockk() private val jwtAuthenticationProcessor: JwtAuthenticationProcessor = mockk() private val userContextAuthenticationProcessor: UserContextAuthenticationProcessor = mockk() + private val callContextAuthenticationProcessor: CallContextAuthenticationProcessor = mockk() private val filter = UserContextWebFilter( oidcAuthenticationProcessor, jwtAuthenticationProcessor, - userContextAuthenticationProcessor + userContextAuthenticationProcessor, + callContextAuthenticationProcessor ) @Test @@ -115,6 +117,59 @@ internal class UserContextWebFilterTest { } } + @Test + fun `callContext authentication triggered`() { + val authenticationToken = mockk() + val context = SecurityContextImpl(authenticationToken) + val webFilterChain = mockk() + + every { + callContextAuthenticationProcessor.authenticate( + authenticationToken, + any(), + webFilterChain + ) + } returns Mono.empty() + + filter + .filter(mockk(), webFilterChain) + .contextWrite { it.put(SecurityContext::class.java, Mono.just(context)) } + .block() + + verify(exactly = 1) { + callContextAuthenticationProcessor.authenticate( + authenticationToken, + any(), + webFilterChain + ) + } + } + + @Test + fun `callContext authentication with null processor logs warning and continues`() { + val authenticationToken = mockk() + val context = SecurityContextImpl(authenticationToken) + val webFilterChain = mockk { + every { filter(any()) } returns Mono.empty() + } + val filterWithoutCallContextProcessor = UserContextWebFilter( + oidcAuthenticationProcessor, + jwtAuthenticationProcessor, + userContextAuthenticationProcessor, + null // No CallContext processor + ) + + filterWithoutCallContextProcessor + .filter(mockk(), webFilterChain) + .contextWrite { it.put(SecurityContext::class.java, Mono.just(context)) } + .block() + + verify(exactly = 1) { webFilterChain.filter(any()) } + verify(exactly = 0) { + callContextAuthenticationProcessor.authenticate(any(), any(), any()) + } + } + @Test fun `unauthenticated resource trigger`() { val webFilterChain = mockk { @@ -123,7 +178,8 @@ internal class UserContextWebFilterTest { val filter = UserContextWebFilter( oidcAuthenticationProcessor, jwtAuthenticationProcessor, - userContextAuthenticationProcessor + userContextAuthenticationProcessor, + callContextAuthenticationProcessor ) filter