Skip to content

Commit 368b01e

Browse files
Merge pull request #93 from gooddata/cbon-lx-1581-callcontext-processing-support
feat: CallContext support for service-to-service
2 parents 50c118b + 608486d commit 368b01e

10 files changed

Lines changed: 777 additions & 4 deletions

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,32 @@ root@a45628275f4a:/# ./tinkey create-keyset --key-template AES256_GCM
7474
}
7575
```
7676

77+
### Call Context Authentication
78+
79+
In addition to OIDC/OAuth2 and Bearer token authentication, the library supports **call context authentication**
80+
for service-to-service communication. This mechanism is designed for scenarios where authentication has already
81+
been performed by an upstream service (e.g., an API gateway).
82+
83+
Key characteristics:
84+
* Authentication information is passed via a custom HTTP header (configurable by the application)
85+
* No re-authentication or metadata lookups are performed (trusts upstream validation)
86+
* Takes precedence over Bearer token authentication when the call context header is present
87+
* Requires implementing [CallContextHeaderProcessor](gooddata-server-oauth2-autoconfigure/src/main/kotlin/CallContextHeaderProcessor.kt)
88+
interface to:
89+
- Define the custom header name via `getHeaderName()`
90+
- Parse the application-specific header format via `parseCallContextHeader()`
91+
92+
#### Security Considerations
93+
94+
**Critical**: This authentication mode bypasses normal security checks and trusts the upstream service completely.
95+
Implementations must ensure:
96+
97+
* **Gateway-level protection**: The API gateway **must** strip/discard the call context header from all external
98+
requests. This is the primary security control.
99+
* **Network segmentation**: Services using this feature should not be directly accessible from untrusted networks.
100+
Deploy behind a properly configured API gateway or service mesh.
101+
* **Audit logging**: All call context authentications should be logged for security monitoring and incident response.
102+
77103
### HTTP endpoints
78104

79105
* **any resource** behind authentication
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright 2025 GoodData Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.gooddata.oauth2.server
17+
18+
import io.github.oshai.kotlinlogging.KotlinLogging
19+
import org.springframework.web.server.ServerWebExchange
20+
import org.springframework.web.server.WebFilterChain
21+
import reactor.core.publisher.Mono
22+
23+
private val logger = KotlinLogging.logger {}
24+
25+
/**
26+
* Context data needed to create a user context.
27+
*/
28+
private data class UserContextData(
29+
val organizationId: String,
30+
val userId: String,
31+
val userName: String?,
32+
val tokenId: String?,
33+
val authMethod: AuthMethod?,
34+
val accessToken: String?
35+
)
36+
37+
/**
38+
* Processes [CallContextAuthenticationToken] and creates user context from call context data.
39+
*
40+
* Unlike other authentication processors, this does NOT fetch organization/user from the authentication store
41+
* because call context authentication represents requests that have already been authenticated by an upstream
42+
* service. The upstream service has already validated credentials, checked for global logout, and verified
43+
* organization/user existence.
44+
*
45+
* This processor delegates header parsing to [CallContextHeaderProcessor] implementation.
46+
*/
47+
class CallContextAuthenticationProcessor(
48+
private val headerProcessor: CallContextHeaderProcessor,
49+
private val userContextProvider: ReactorUserContextProvider
50+
) : AuthenticationProcessor<CallContextAuthenticationToken>(userContextProvider) {
51+
52+
override fun authenticate(
53+
authenticationToken: CallContextAuthenticationToken,
54+
exchange: ServerWebExchange,
55+
chain: WebFilterChain
56+
): Mono<Void> {
57+
return try {
58+
val authDetails = headerProcessor.parseCallContextHeader(authenticationToken.callContextHeaderValue)
59+
?: throw CallContextAuthenticationException("Call context header contains no user information")
60+
61+
val authMethod = try {
62+
AuthMethod.valueOf(authDetails.authMethod)
63+
} catch (e: IllegalArgumentException) {
64+
logger.logError(e) {
65+
withAction("callContextAuth")
66+
withState("failed")
67+
withMessage {
68+
"Invalid authMethod '${authDetails.authMethod}' in CallContext header. " +
69+
"Valid values: ${AuthMethod.entries.joinToString { it.name }}"
70+
}
71+
}
72+
throw CallContextAuthenticationException(
73+
"Invalid authentication method in call context"
74+
)
75+
}
76+
77+
logger.logInfo {
78+
withAction("callContextAuth")
79+
withState("authenticated")
80+
withOrganizationId(authDetails.organizationId)
81+
withUserId(authDetails.userId)
82+
withAuthenticationMethod(authMethod.name)
83+
authDetails.tokenId?.let { withTokenId(it) }
84+
withMessage { "Processed authenticated call context" }
85+
}
86+
87+
val userContextData = UserContextData(
88+
organizationId = authDetails.organizationId,
89+
userId = authDetails.userId,
90+
userName = null,
91+
tokenId = authDetails.tokenId,
92+
authMethod = authMethod,
93+
accessToken = null
94+
)
95+
withUserContext(userContextData) {
96+
chain.filter(exchange)
97+
}
98+
} catch (e: CallContextAuthenticationException) {
99+
val remoteAddress = exchange.request.remoteAddress?.address?.hostAddress
100+
logger.logError(e) {
101+
withAction("callContextAuth")
102+
withState("failed")
103+
withMessage { "Call context authentication failed from $remoteAddress" }
104+
}
105+
Mono.error(e)
106+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
107+
// Must catch all exceptions to prevent auth chain disruption
108+
logger.logError(e) {
109+
withAction("callContextAuth")
110+
withState("error")
111+
withMessage { "Unexpected error during call context authentication" }
112+
}
113+
Mono.error(CallContextAuthenticationException("Authentication failed", e))
114+
}
115+
}
116+
117+
private fun <T> withUserContext(
118+
userContextData: UserContextData,
119+
monoProvider: () -> Mono<T>
120+
): Mono<T> {
121+
val contextView = userContextProvider.getContextView(
122+
organizationId = userContextData.organizationId,
123+
userId = userContextData.userId,
124+
userName = userContextData.userName,
125+
tokenId = userContextData.tokenId,
126+
authMethod = userContextData.authMethod,
127+
accessToken = userContextData.accessToken
128+
)
129+
130+
return monoProvider().contextWrite(contextView)
131+
}
132+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2025 GoodData Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.gooddata.oauth2.server
17+
18+
import org.springframework.security.authentication.AbstractAuthenticationToken
19+
20+
/**
21+
* Authentication token created from call context header.
22+
*
23+
* This represents authentication that has already been validated by an upstream service.
24+
* The call context header can only come from trusted internal services.
25+
*
26+
* @property callContextHeaderValue The raw call context header value
27+
*/
28+
class CallContextAuthenticationToken(
29+
val callContextHeaderValue: String
30+
) : AbstractAuthenticationToken(emptyList()) {
31+
32+
init {
33+
// Mark as authenticated since upstream service already validated
34+
isAuthenticated = true
35+
}
36+
37+
override fun getCredentials(): Any? = null
38+
39+
override fun getPrincipal(): String = callContextHeaderValue
40+
41+
override fun toString(): String =
42+
"CallContextAuthenticationToken[headerPresent=true]"
43+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2025 GoodData Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.gooddata.oauth2.server
17+
18+
import io.github.oshai.kotlinlogging.KotlinLogging
19+
import org.springframework.security.core.context.ReactiveSecurityContextHolder
20+
import org.springframework.security.core.context.SecurityContextImpl
21+
import org.springframework.web.server.ServerWebExchange
22+
import org.springframework.web.server.WebFilter
23+
import org.springframework.web.server.WebFilterChain
24+
import reactor.core.publisher.Mono
25+
26+
private val logger = KotlinLogging.logger {}
27+
28+
/**
29+
* WebFilter that detects call context header and creates Spring Security authentication.
30+
*
31+
* This filter runs before the main authentication filters and takes precedence over Bearer token
32+
* authentication when the call context header is present AND contains user information.
33+
* The call context header should only come from trusted internal services.
34+
*
35+
* If the header is present with user info, it creates a [CallContextAuthenticationToken] that will be processed
36+
* by [CallContextAuthenticationProcessor].
37+
* If the header is absent or has no user info, the request continues to other authentication mechanisms.
38+
*/
39+
class CallContextAuthenticationWebFilter(
40+
private val headerProcessor: CallContextHeaderProcessor?
41+
) : WebFilter {
42+
43+
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
44+
// If no processor configured or no header, skip CallContext authentication
45+
val callContextHeader = headerProcessor?.getHeaderName()
46+
?.let { exchange.request.headers.getFirst(it) }
47+
48+
if (callContextHeader == null) {
49+
return chain.filter(exchange)
50+
}
51+
52+
// Check if the CallContext has user information before creating an authentication token
53+
return try {
54+
val authDetails = headerProcessor?.parseCallContextHeader(callContextHeader)
55+
56+
// Only proceed with CallContext authentication if we got auth details
57+
if (authDetails != null) {
58+
val remoteHost = exchange.request.remoteAddress?.address?.hostAddress ?: "unknown"
59+
logger.info {
60+
"Call context authentication initiated from $remoteHost"
61+
}
62+
63+
val authToken = CallContextAuthenticationToken(callContextHeader)
64+
val securityContext = SecurityContextImpl(authToken)
65+
66+
chain.filter(exchange)
67+
.contextWrite(
68+
ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext))
69+
)
70+
} else {
71+
// CallContext has no user info, skip and let the regular authentication chain handle it
72+
chain.filter(exchange)
73+
}
74+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
75+
// Must catch all exceptions for graceful fallback to normal auth
76+
val remoteHost = exchange.request.remoteAddress?.address?.hostAddress
77+
logger.warn(e) {
78+
"Failed to parse CallContext header from $remoteHost, " +
79+
"falling back to normal authentication chain"
80+
}
81+
chain.filter(exchange)
82+
}
83+
}
84+
}
85+
86+
/**
87+
* Exception thrown when CallContext authentication fails.
88+
*/
89+
class CallContextAuthenticationException(
90+
message: String,
91+
cause: Throwable? = null
92+
) : RuntimeException(message, cause)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2025 GoodData Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.gooddata.oauth2.server
17+
18+
/**
19+
* Interface for processing call context headers from internal service-to-service calls.
20+
*
21+
* Implementations should parse the call context header and extract authentication
22+
* information that has already been validated by an upstream service.
23+
*
24+
* The implementation should be provided by the application using this library.
25+
*/
26+
interface CallContextHeaderProcessor {
27+
28+
/**
29+
* The name of the HTTP header to use for call context authentication.
30+
*
31+
* @return The HTTP header name (e.g., "X-Custom-Context-Header")
32+
*/
33+
fun getHeaderName(): String
34+
35+
/**
36+
* Parses the call context header value and returns authentication details.
37+
*
38+
* @param headerValue The header value (typically Base64-encoded)
39+
* @return [CallContextAuth] containing authentication information, or null if the header has no user or
40+
* organization information (which signals that CallContext authentication should be skipped)
41+
*/
42+
fun parseCallContextHeader(headerValue: String): CallContextAuth?
43+
}
44+
45+
/**
46+
* Authentication details extracted from a call context header.
47+
*
48+
* @property organizationId The organization ID
49+
* @property userId The user ID
50+
* @property authMethod The authentication method (e.g., "API_TOKEN", "OIDC", "JWT")
51+
* @property tokenId The token ID if applicable
52+
*/
53+
data class CallContextAuth(
54+
val organizationId: String,
55+
val userId: String,
56+
val authMethod: String,
57+
val tokenId: String? = null
58+
)

0 commit comments

Comments
 (0)