Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CallContextAuthenticationToken>(userContextProvider) {

override fun authenticate(
authenticationToken: CallContextAuthenticationToken,
exchange: ServerWebExchange,
chain: WebFilterChain
): Mono<Void> {
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 <T> withUserContext(
userContextData: UserContextData,
monoProvider: () -> Mono<T>
): Mono<T> {
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)
}
}
Original file line number Diff line number Diff line change
@@ -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]"
}
Original file line number Diff line number Diff line change
@@ -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<Void> {
// 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)
Original file line number Diff line number Diff line change
@@ -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
)
Loading