Skip to content

Commit bcd9ab2

Browse files
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 * 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 parse the application-specific header format #### 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
1 parent 50c118b commit bcd9ab2

10 files changed

+918
-4
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,30 @@ 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
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 parse the application-specific header format
89+
90+
#### Security Considerations
91+
92+
**Critical**: This authentication mode bypasses normal security checks and trusts the upstream service completely.
93+
Implementations must ensure:
94+
95+
* **Gateway-level protection**: The API gateway **must** strip/discard the call context header from all external
96+
requests. This is the primary security control.
97+
* **Network segmentation**: Services using this feature should not be directly accessible from untrusted networks.
98+
Deploy behind a properly configured API gateway or service mesh.
99+
* **Audit logging**: All call context authentications should be logged for security monitoring and incident response.
100+
77101
### HTTP endpoints
78102

79103
* **any resource** behind authentication
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
60+
val organizationId = authDetails[CallContextKeys.ORGANIZATION_ID]
61+
?: throw CallContextAuthenticationException("Missing organization ID in call context")
62+
val userId = authDetails[CallContextKeys.USER_ID]
63+
?: throw CallContextAuthenticationException("Missing user ID in call context")
64+
val authMethodStr = authDetails[CallContextKeys.AUTH_METHOD]
65+
?: throw CallContextAuthenticationException("Missing authentication method in call context")
66+
67+
val tokenId = authDetails[CallContextKeys.TOKEN_ID]
68+
69+
val authMethod = try {
70+
AuthMethod.valueOf(authMethodStr)
71+
} catch (e: IllegalArgumentException) {
72+
logger.error {
73+
"Invalid authMethod '$authMethodStr' in CallContext header. " +
74+
"Valid values: ${AuthMethod.entries.joinToString { it.name }}. "
75+
}
76+
throw CallContextAuthenticationException(
77+
"Invalid authentication method in call context"
78+
)
79+
}
80+
81+
logger.info {
82+
"Processed authenticated Call context: org=$organizationId, user=$userId, method=${authMethod.name}"
83+
}
84+
85+
val userContextData = UserContextData(
86+
organizationId = organizationId,
87+
userId = userId,
88+
userName = null,
89+
tokenId = tokenId,
90+
authMethod = authMethod,
91+
accessToken = null
92+
)
93+
withUserContext(userContextData) {
94+
chain.filter(exchange)
95+
}
96+
} catch (e: CallContextAuthenticationException) {
97+
val remoteAddress = exchange.request.remoteAddress?.address?.hostAddress
98+
logger.error { "Call context authentication failed from $remoteAddress" }
99+
Mono.error(e)
100+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
101+
// Must catch all exceptions to prevent auth chain disruption
102+
logger.error(e) { "Unexpected error during call context authentication" }
103+
Mono.error(CallContextAuthenticationException("Authentication failed", e))
104+
}
105+
}
106+
107+
private fun <T> withUserContext(
108+
userContextData: UserContextData,
109+
monoProvider: () -> Mono<T>
110+
): Mono<T> {
111+
val contextView = userContextProvider.getContextView(
112+
organizationId = userContextData.organizationId,
113+
userId = userContextData.userId,
114+
userName = userContextData.userName,
115+
tokenId = userContextData.tokenId,
116+
authMethod = userContextData.authMethod,
117+
accessToken = userContextData.accessToken
118+
)
119+
120+
return monoProvider().contextWrite(contextView)
121+
}
122+
}
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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
* Validates that the authentication details map contains all required fields for CallContext authentication.
30+
*
31+
* @param authDetails Map of authentication details from the header processor
32+
* @return true if all required fields are present and non-null, false otherwise
33+
*/
34+
internal fun hasRequiredCallContextFields(authDetails: Map<String, String?>): Boolean {
35+
return authDetails[CallContextKeys.USER_ID] != null &&
36+
authDetails[CallContextKeys.ORGANIZATION_ID] != null &&
37+
authDetails[CallContextKeys.AUTH_METHOD] != null
38+
}
39+
40+
/**
41+
* WebFilter that detects call context header and creates Spring Security authentication.
42+
*
43+
* This filter runs before the main authentication filters and takes precedence over Bearer token
44+
* authentication when the call context header is present AND contains user information.
45+
* The call context header should only come from trusted internal services.
46+
*
47+
* If the header is present with user info, it creates a [CallContextAuthenticationToken] that will be processed
48+
* by [CallContextAuthenticationProcessor].
49+
* If the header is absent or has no user info, the request continues to other authentication mechanisms.
50+
*/
51+
class CallContextAuthenticationWebFilter(
52+
private val headerProcessor: CallContextHeaderProcessor?
53+
) : WebFilter {
54+
55+
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
56+
val callContextHeader = exchange.request.headers.getFirst(CALL_CONTEXT_HEADER_NAME)
57+
58+
// If no header or no processor, skip CallContext authentication
59+
if (callContextHeader == null || headerProcessor == null) {
60+
return chain.filter(exchange)
61+
}
62+
63+
// Check if the CallContext has user information before creating an authentication token
64+
return try {
65+
val authDetails = headerProcessor.parseCallContextHeader(callContextHeader)
66+
67+
// Only proceed with CallContext authentication if there's user AND organization info
68+
if (hasRequiredCallContextFields(authDetails)) {
69+
val remoteHost = exchange.request.remoteAddress?.address?.hostAddress ?: "unknown"
70+
logger.info {
71+
"Call context authentication initiated from $remoteHost"
72+
}
73+
74+
val authToken = CallContextAuthenticationToken(callContextHeader)
75+
val securityContext = SecurityContextImpl(authToken)
76+
77+
chain.filter(exchange)
78+
.contextWrite(
79+
ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext))
80+
)
81+
} else {
82+
// CallContext has no user info, skip and let the regular authentication chain handle it
83+
chain.filter(exchange)
84+
}
85+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
86+
// Must catch all exceptions for graceful fallback to normal auth
87+
val remoteHost = exchange.request.remoteAddress?.address?.hostAddress
88+
logger.warn(e) {
89+
"Failed to parse CallContext header from $remoteHost, " +
90+
"falling back to normal authentication chain"
91+
}
92+
chain.filter(exchange)
93+
}
94+
}
95+
}
96+
97+
/**
98+
* Exception thrown when CallContext authentication fails.
99+
*/
100+
class CallContextAuthenticationException(
101+
message: String,
102+
cause: Throwable? = null
103+
) : RuntimeException(message, cause)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
fun interface CallContextHeaderProcessor {
27+
28+
/**
29+
* Parses the call context header value and returns a map of authentication details.
30+
*
31+
* The returned map should contain keys defined in [CallContextKeys]:
32+
* - [CallContextKeys.ORGANIZATION_ID] (required): The organization ID
33+
* - [CallContextKeys.USER_ID] (required): The user ID
34+
* - [CallContextKeys.AUTH_METHOD] (required): The authentication method (e.g., "API_TOKEN", "OIDC", "JWT")
35+
* - [CallContextKeys.TOKEN_ID] (optional): The token ID if applicable
36+
*
37+
* @param headerValue The header value (typically Base64-encoded)
38+
* @return Map containing authentication details, or empty map if the header has no user or organization information
39+
* (which signals that CallContext authentication should be skipped)
40+
*/
41+
fun parseCallContextHeader(headerValue: String): Map<String, String?>
42+
}
43+
44+
/**
45+
* Standard keys for the map returned by [CallContextHeaderProcessor.parseCallContextHeader].
46+
*/
47+
object CallContextKeys {
48+
const val ORGANIZATION_ID = "organizationId"
49+
const val USER_ID = "userId"
50+
const val AUTH_METHOD = "authMethod"
51+
const val TOKEN_ID = "tokenId"
52+
}
53+
54+
/**
55+
* The name of the HTTP header used to convey call context information in API calls across backend services.
56+
*/
57+
const val CALL_CONTEXT_HEADER_NAME = "X-GDC-CALL-CONTEXT"

0 commit comments

Comments
 (0)