diff --git a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallCredentials.kt b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallCredentials.kt new file mode 100644 index 000000000..965338d9b --- /dev/null +++ b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallCredentials.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.grpc.client + +import kotlinx.rpc.grpc.GrpcMetadata +import kotlinx.rpc.grpc.descriptor.MethodDescriptor +import kotlinx.rpc.grpc.plus + +/** + * Provides per-call authentication credentials for gRPC calls. + * + * Call credentials are used to attach authentication information (such as tokens, API keys, or signatures) + * to individual gRPC calls through metadata headers. Unlike client credentials, which establish + * the transport security layer (e.g., TLS), call credentials operate at the application layer + * and can be dynamically generated for each request. + * + * ## Usage + * + * Implement this interface to create custom authentication mechanisms: + * + * ```kotlin + * class BearerTokenCredentials(private val token: String) : GrpcCallCredentials { + * override suspend fun Context.getRequestMetadata(): GrpcMetadata { + * return buildGrpcMetadata { + * append("Authorization", "Bearer $token") + * } + * } + * } + * ``` + * + * ## Context-Aware Credentials + * + * Use the [Context] to implement sophisticated authentication strategies: + * + * ```kotlin + * class MethodScopedCredentials : GrpcCallCredentials { + * override suspend fun Context.getRequestMetadata(): GrpcMetadata { + * val scope = when (methodName) { + * "GetUser" -> "read:users" + * "UpdateUser" -> "write:users" + * else -> "default" + * } + * val token = fetchTokenWithScope(scope) + * return buildGrpcMetadata { + * append("Authorization", "Bearer $token") + * } + * } + * } + * ``` + * + * ## Combining Credentials + * + * Credentials can be combined using the [plus] operator or [combine] function: + * + * ```kotlin + * val credentials = TlsClientCredentials(...) + BearerTokenCredentials("my-token") + * ``` + * + * ## Transport Security + * + * By default, call credentials require transport security (TLS) to prevent credential leakage. + * Override [requiresTransportSecurity] to `false` only for testing or non-production environments. + * + * @see getRequestMetadata + * @see Context + * @see requiresTransportSecurity + * @see plus + * @see combine + */ +public interface GrpcCallCredentials { + + /** + * Retrieves authentication metadata for the gRPC call. + * + * This method is invoked before each gRPC call to generate authentication headers or metadata. + * Implementations should return a [GrpcMetadata] object containing the necessary authentication + * information for the request. + * + * The method is suspending to allow asynchronous operations such as token retrieval from secure storage. + * + * ## Context Information + * + * The [Context] receiver provides access to call-specific information: + * - [Context.methodName]: The method being invoked (for method-specific auth) + * - [Context.authority]: The target authority (for tenant-aware auth) + * + * ## Examples + * + * Simple bearer token: + * ```kotlin + * override suspend fun Context.getRequestMetadata(): GrpcMetadata { + * return buildGrpcMetadata { + * append("Authorization", "Bearer $token") + * } + * } + * ``` + * + * Throwing a [kotlinx.rpc.grpc.StatusException] to fail the call: + * ```kotlin + * override suspend fun Context.getRequestMetadata(): GrpcMetadata { + * val token = try { + * refreshToken() + * } catch (e: Exception) { + * throw StatusException(Status(StatusCode.UNAUTHENTICATED, "Token refresh failed")) + * } + * + * return buildGrpcMetadata { + * append("Authorization", "Bearer $token") + * } + * } + * ``` + * + * @receiver Context information about the call being authenticated. + * @return Metadata containing authentication information to attach to the request. + * @throws kotlinx.rpc.grpc.StatusException to abort the call with a specific gRPC status. + */ + public suspend fun Context.getRequestMetadata(): GrpcMetadata + + /** + * Indicates whether this credential requires transport security (TLS). + * + * When `true` (the default), the credential will only be applied to calls over secure transports. + * If transport security is not present, the call will fail with [kotlinx.rpc.grpc.StatusCode.UNAUTHENTICATED]. + * + * Set to `false` only for credentials that are safe to send over insecure connections, + * such as in testing environments or for non-sensitive authentication mechanisms. + * + * @return `true` if transport security is required, `false` otherwise. + */ + public val requiresTransportSecurity: Boolean + get() = true + + /** + * Context information available when retrieving call credentials. + * + * Provides metadata about the RPC call to enable method-specific authentication strategies. + * + * @property methodName The method name of the RPC being invoked. + * @property authority The authority (host:port) for this call. + */ + // TODO: check whether we should add GrpcCallOptions in the context (KRPC-232) + public data class Context( + val authority: String, + val methodName: String, + ) +} + +/** + * Combines two call credentials into a single credential that applies both. + * + * The resulting credential will apply both sets of credentials in order, allowing + * multiple authentication mechanisms to be used simultaneously. For example, + * combining channel credentials with call credentials, or applying multiple + * authentication headers to the same call. + * + * The combined credential requires transport security if either of the original + * credentials requires it. + * + * ## Example + * + * ```kotlin + * val tlsCreds = TlsClientCredentials { ... } + * val bearerToken = BearerTokenCredentials("my-token") + * val combined = tlsCreds + bearerToken + * ``` + * + * Multiple credentials can be chained: + * ```kotlin + * val combined = creds1 + creds2 + creds3 + * ``` + * + * @param other The credential to combine with this one. + * @return A new credential that applies both credentials. + * @see combine + */ +public operator fun GrpcCallCredentials.plus(other: GrpcCallCredentials): GrpcCallCredentials { + return CombinedCallCredentials(this, other) +} + +/** + * Combines two call credentials into a single credential that applies both. + * + * This is an alias for the [plus] operator, providing a more explicit method name + * for combining credentials. + * + * @param other The credential to combine with this one. + * @return A new credential that applies both credentials. + * @see plus + */ +public fun GrpcCallCredentials.combine(other: GrpcCallCredentials): GrpcCallCredentials = this + other + +/** + * A call credential that performs no authentication. + * + * This is useful as a no-op placeholder or for disabling authentication in specific scenarios. + * Since it performs no authentication, it does not require transport security. + * + * ## Example + * + * ```kotlin + * val credentials = if (useAuth) { + * BearerTokenCredentials(token) + * } else { + * EmptyCallCredentials + * } + * ``` + */ +public object EmptyCallCredentials : GrpcCallCredentials { + override suspend fun GrpcCallCredentials.Context.getRequestMetadata(): GrpcMetadata { + return GrpcMetadata() + } + override val requiresTransportSecurity: Boolean = false +} + +internal class CombinedCallCredentials( + private val first: GrpcCallCredentials, + private val second: GrpcCallCredentials +) : GrpcCallCredentials { + override suspend fun GrpcCallCredentials.Context.getRequestMetadata(): GrpcMetadata { + val firstMetadata = with(first) { getRequestMetadata() } + val secondMetadata = with(second) { getRequestMetadata() } + return firstMetadata + secondMetadata + } + + override val requiresTransportSecurity: Boolean = first.requiresTransportSecurity || second.requiresTransportSecurity +} diff --git a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.kt b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.kt index 8bf380bad..f300e38de 100644 --- a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.kt +++ b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.kt @@ -49,4 +49,49 @@ public class GrpcCallOptions { * @see GrpcCompression */ public var compression: GrpcCompression = GrpcCompression.None + + /** + * Per-call authentication credentials to apply to this RPC. + * + * Call credentials allow dynamic, per-request authentication by adding metadata headers + * such as bearer tokens, API keys, or custom authentication information. + * + * ## Default Behavior + * Defaults to [EmptyCallCredentials], which attaches no authentication headers. + * + * ## Usage Patterns + * + * ### Setting in Client Interceptors + * Call credentials can be dynamically added or modified in client interceptors: + * + * ```kotlin + * val client = GrpcClient("example.com", 443) { + * interceptors { + * clientInterceptor { + * callOptions.callCredentials += BearerTokenCredentials(getToken()) + * proceed(it) + * } + * } + * } + * ``` + * + * ### Combining Multiple Credentials + * Multiple call credentials can be combined using the `+` operator: + * + * ```kotlin + * callOptions.callCredentials = bearerToken + apiKey + * // or incrementally: + * callOptions.callCredentials += additionalCredential + * ``` + * + * ### Transport Security + * If any call credential requires transport security ([GrpcCallCredentials.requiresTransportSecurity]), + * the call will fail with [kotlinx.rpc.grpc.StatusCode.UNAUTHENTICATED] unless the channel + * is configured with TLS credentials (which is the default, except if the client uses `plaintext()`). + * + * @see GrpcCallCredentials + * @see EmptyCallCredentials + * @see GrpcCallCredentials.plus + */ + public var callCredentials: GrpcCallCredentials = EmptyCallCredentials } \ No newline at end of file diff --git a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcClient.kt b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcClient.kt index d08fd0797..ad32d87cd 100644 --- a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcClient.kt +++ b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcClient.kt @@ -33,13 +33,13 @@ private typealias RequestClient = Any /** * GrpcClient manages gRPC communication by providing implementation for making asynchronous RPC calls. - * - * @field channel The [kotlinx.rpc.grpc.client.internal.ManagedChannel] used to communicate with remote gRPC services. */ public class GrpcClient internal constructor( internal val channel: ManagedChannel, messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver, internal val interceptors: List, + // the default call credentials that are automatically attached to all calls made with this client + internal val callCredentials: GrpcCallCredentials, ) : RpcClient { private val delegates = RpcInternalConcurrentHashMap() private val messageCodecResolver = messageCodecResolver + ThrowingMessageCodecResolver @@ -182,7 +182,8 @@ private fun GrpcClient( config: GrpcClientConfiguration, ): GrpcClient { val channel = builder.applyConfig(config).buildChannel() - return GrpcClient(channel, config.messageCodecResolver, config.interceptors) + val callCredentials = config.credentials?.realCallCredentials ?: EmptyCallCredentials + return GrpcClient(channel, config.messageCodecResolver, config.interceptors, callCredentials) } @@ -234,7 +235,7 @@ public class GrpcClientConfiguration internal constructor() { * @see tls * @see plaintext */ - public var credentials: ClientCredentials? = null + public var credentials: GrpcClientCredentials? = null /** * Overrides the authority used with TLS and HTTP virtual hosting. @@ -272,7 +273,7 @@ public class GrpcClientConfiguration internal constructor() { * * @return An insecure [ClientCredentials] instance that must be passed to [credentials]. */ - public fun plaintext(): ClientCredentials = createInsecureClientCredentials() + public fun plaintext(): GrpcClientCredentials = GrpcInsecureClientCredentials() /** * Configures and creates secure client credentials for the gRPC client. @@ -286,13 +287,13 @@ public class GrpcClientConfiguration internal constructor() { * Alternatively, you can use the [TlsClientCredentials] constructor. * * @param configure A configuration block that allows setting up the TLS parameters - * using the [TlsClientCredentialsBuilder]. + * using the [GrpcTlsClientCredentialsBuilder]. * @return A secure [ClientCredentials] instance that must be passed to [credentials]. * * @see credentials */ - public fun tls(configure: TlsClientCredentialsBuilder.() -> Unit): ClientCredentials = - TlsClientCredentials(configure) + public fun tls(configure: GrpcTlsClientCredentialsBuilder.() -> Unit): GrpcClientCredentials = + GrpcTlsClientCredentials(configure) /** * Configures keep-alive settings for the gRPC client. diff --git a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/credentials.kt b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/credentials.kt index 7d069d677..52cdef46a 100644 --- a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/credentials.kt +++ b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/credentials.kt @@ -4,30 +4,244 @@ package kotlinx.rpc.grpc.client -import kotlinx.rpc.internal.utils.InternalRpcApi +/** + * Base class for client channel credentials. + * + * Client credentials define the security mechanism used to establish a connection to the gRPC server. + * Unlike [GrpcCallCredentials] which operate at the application layer for per-call authentication, + * client credentials establish the transport layer security. + * + * ## Types of Credentials + * + * - **[GrpcInsecureClientCredentials]**: No transport security (plaintext) + * - **[GrpcTlsClientCredentials]**: TLS/SSL transport security with optional mutual TLS (mTLS) + * + * Client credentials can be combined with call credentials using the [plus] operator: + * ```kotlin + * val credentials = GrpcTlsClientCredentials { ... } + BearerTokenCredentials(token) + * ``` + * + * @see GrpcTlsClientCredentials + * @see GrpcInsecureClientCredentials + * @see GrpcCallCredentials + */ +@OptIn(ExperimentalSubclassOptIn::class) +@SubclassOptInRequired +public interface GrpcClientCredentials + +/** + * Combines client credentials with call credentials. + * + * This operator allows attaching per-call authentication credentials to a client credential, + * enabling both transport security (via [GrpcClientCredentials]) and application-layer authentication + * (via [GrpcCallCredentials]) to be used together for all requests of the client. + * + * ## Example + * + * ```kotlin + * val tlsCredentials = GrpcTlsClientCredentials { + * trustManager(serverCertPem) + * } + * val bearerToken = BearerTokenCredentials("my-token") + * val combined = tlsCredentials + bearerToken + * + * val client = GrpcClient("example.com", 443) { + * credentials = combined + * } + * ``` + * + * @param other The call credentials to combine with this client credential. + * @return A new credential that includes both client and call credentials. + * @see combine + * @see GrpcCallCredentials + */ +public operator fun GrpcClientCredentials.plus(other: GrpcCallCredentials): GrpcClientCredentials { + return GrpcCombinedClientCredentials.create(this, other) +} -public expect abstract class ClientCredentials +/** + * Combines client credentials with call credentials. + * + * This is an alias for the [plus] operator, providing a more explicit method name + * for combining credentials. + * + * @param other The call credentials to combine with this client credential. + * @return A new credential that includes both client and call credentials. + * @see plus + */ +public fun GrpcClientCredentials.combine(other: GrpcCallCredentials): GrpcClientCredentials = this + other -public expect class InsecureClientCredentials : ClientCredentials -// we need a wrapper for InsecureChannelCredentials as our constructor would conflict with the private -// java constructor. -@InternalRpcApi -public expect fun createInsecureClientCredentials(): ClientCredentials -public expect class TlsClientCredentials : ClientCredentials +/** + * Plaintext credentials with no transport security. + * + * Use this credential type for unencrypted connections to gRPC servers. This should only + * be used in development or testing environments or when connecting to services within + * a secure network perimeter. + * + * ## Example + * + * ```kotlin + * val client = GrpcClient("localhost", 9090) { + * credentials = plaintext() + * } + * ``` + * + * **Warning**: Plaintext credentials transmit data without encryption. Do not use in production + * for sensitive data or over untrusted networks. + * + * @see GrpcTlsClientCredentials + */ +public class GrpcInsecureClientCredentials : GrpcClientCredentials -public fun TlsClientCredentials(configure: TlsClientCredentialsBuilder.() -> Unit = {}): ClientCredentials { - val builder = TlsClientCredentialsBuilder() - builder.configure() - return builder.build() -} +/** + * TLS/SSL credentials for secure transport. + * + * TLS credentials establish an encrypted connection to the gRPC server using TLS/SSL. + * This credential type supports both server authentication (standard TLS) and mutual TLS (mTLS) + * where the client also authenticates to the server. + * + * ## Server Authentication (TLS) + * + * Verify the server's identity using a trust manager with root certificates: + * + * ```kotlin + * val tlsCredentials = GrpcTlsClientCredentials { + * trustManager(serverCertPem) + * } + * + * val client = GrpcClient("example.com", 443) { + * credentials = tlsCredentials + * } + * ``` + * + * ## Mutual TLS (mTLS) + * + * For mutual authentication, provide both trust manager and key manager: + * + * ```kotlin + * val credentials = GrpcTlsClientCredentials { + * trustManager(caCertPem) // Server's CA certificate + * keyManager(clientCertPem, clientKeyPem) // Client's certificate and private key + * } + * ``` + * + * ## Default System Trust Store + * + * If no trust manager is configured, the system's default trust store is used: + * + * ```kotlin + * val credentials = TlsClientCredentials { } // Uses system CA certificates + * ``` + * + * **Note**: The server certificate's Common Name (CN) or Subject Alternative Name (SAN) + * must match the authority specified in the client configuration, or the connection will fail. + * + * @see GrpcTlsClientCredentialsBuilder + * @see GrpcInsecureClientCredentials + */ +public class GrpcTlsClientCredentials(internal val configure: GrpcTlsClientCredentialsBuilder.() -> Unit = {}) : GrpcClientCredentials -public interface TlsClientCredentialsBuilder { - public fun trustManager(rootCertsPem: String): TlsClientCredentialsBuilder - public fun keyManager(certChainPem: String, privateKeyPem: String): TlsClientCredentialsBuilder +/** + * Builder for configuring [GrpcTlsClientCredentials]. + * + * This builder provides methods to configure trust managers for server authentication + * and key managers for client authentication (mTLS). + * + * @see GrpcTlsClientCredentials + * @see trustManager + * @see keyManager + */ +public interface GrpcTlsClientCredentialsBuilder { + /** + * Configures the trust manager with root CA certificates for server authentication. + * + * The trust manager validates the server's certificate chain. The provided root certificates + * are used to verify that the server's certificate is signed by a trusted CA. + * + * If not specified, the system's default trust store is used. + * + * ## Example + * + * ```kotlin + * TlsClientCredentials { + * trustManager(""" + * -----BEGIN CERTIFICATE----- + * MIIDXTCCAkWgAwIBAgIJAKl... + * -----END CERTIFICATE----- + * """.trimIndent()) + * } + * ``` + * + * @param rootCertsPem PEM-encoded root CA certificates for validating the server's certificate. + * @return This builder for chaining. + * @see keyManager + */ + public fun trustManager(rootCertsPem: String): GrpcTlsClientCredentialsBuilder + + /** + * Configures the key manager with client certificate and private key for mutual TLS (mTLS). + * + * The key manager enables the client to authenticate itself to the server. This is required + * when the server is configured to require or request client certificates. + * + * ## Example + * + * ```kotlin + * TlsClientCredentials { + * trustManager(caCertPem) + * keyManager( + * certChainPem = """ + * -----BEGIN CERTIFICATE----- + * MIIDXTCCAkWgAwIBAgIJAKl... + * -----END CERTIFICATE----- + * """.trimIndent(), + * privateKeyPem = """ + * -----BEGIN PRIVATE KEY----- + * MIIEvQIBADANBgkqhkiG9w0... + * -----END PRIVATE KEY----- + * """.trimIndent() + * ) + * } + * ``` + * + * @param certChainPem PEM-encoded certificate chain for the client, starting with the client certificate. + * @param privateKeyPem PEM-encoded private key corresponding to the client certificate. + * @return This builder for chaining. + * @see trustManager + */ + public fun keyManager(certChainPem: String, privateKeyPem: String): GrpcTlsClientCredentialsBuilder } -internal expect fun TlsClientCredentialsBuilder(): TlsClientCredentialsBuilder +/** + * Returns the unflattened [GrpcClientCredentials] in case of a [GrpcCombinedClientCredentials]. + */ +internal val GrpcClientCredentials.realClientCredentials + get() = if (this is GrpcCombinedClientCredentials) clientCredentials else this + +/** + * Returns the potential [GrpcCallCredentials] in case of a [GrpcCombinedClientCredentials]. + */ +internal val GrpcClientCredentials.realCallCredentials + get() = if (this is GrpcCombinedClientCredentials) callCredentials else EmptyCallCredentials + +/** + * Combines a [GrpcClientCredentials] with a [GrpcCallCredentials], which can be expected by the + * [GrpcClient] at configuration. This matches the API semantics of the official gRPC libraries. + */ +internal class GrpcCombinedClientCredentials private constructor( + internal val clientCredentials: GrpcClientCredentials, + internal val callCredentials: GrpcCallCredentials, +): GrpcClientCredentials { -internal expect fun TlsClientCredentialsBuilder.build(): ClientCredentials + companion object { + internal fun create(clientCredentials: GrpcClientCredentials, callCredentials: GrpcCallCredentials): GrpcCombinedClientCredentials { + // flat nested combined credentials + return GrpcCombinedClientCredentials( + clientCredentials.realClientCredentials, + clientCredentials.realCallCredentials.combine(callCredentials) + ) + } + } +} diff --git a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.kt b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.kt index e86a3b1f8..d98e626c5 100644 --- a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.kt +++ b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.kt @@ -7,6 +7,7 @@ package kotlinx.rpc.grpc.client.internal import kotlinx.rpc.grpc.client.GrpcCallOptions import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.internal.utils.InternalRpcApi +import kotlin.coroutines.CoroutineContext @InternalRpcApi public expect abstract class GrpcChannel @@ -15,4 +16,5 @@ public expect abstract class GrpcChannel public expect fun GrpcChannel.createCall( methodDescriptor: MethodDescriptor, callOptions: GrpcCallOptions, + coroutineContext: CoroutineContext, ): ClientCall \ No newline at end of file diff --git a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.kt b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.kt index 82b782985..ce604bfc0 100644 --- a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.kt +++ b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.kt @@ -6,8 +6,8 @@ package kotlinx.rpc.grpc.client.internal -import kotlinx.rpc.grpc.client.ClientCredentials import kotlinx.rpc.grpc.client.GrpcClientConfiguration +import kotlinx.rpc.grpc.client.GrpcClientCredentials import kotlinx.rpc.internal.utils.InternalRpcApi import kotlin.time.Duration @@ -78,13 +78,13 @@ public expect abstract class ManagedChannelBuilder> public expect fun ManagedChannelBuilder( hostname: String, port: Int, - credentials: ClientCredentials? = null, + credentials: GrpcClientCredentials? = null, ): ManagedChannelBuilder<*> @InternalRpcApi public expect fun ManagedChannelBuilder( target: String, - credentials: ClientCredentials? = null, + credentials: GrpcClientCredentials? = null, ): ManagedChannelBuilder<*> internal expect fun ManagedChannelBuilder<*>.applyConfig(config: GrpcClientConfiguration): ManagedChannelBuilder<*> diff --git a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/suspendClientCalls.kt b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/suspendClientCalls.kt index 47fb7e60f..2fef24f64 100644 --- a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/suspendClientCalls.kt +++ b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/suspendClientCalls.kt @@ -6,6 +6,7 @@ package kotlinx.rpc.grpc.client.internal import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -24,6 +25,7 @@ import kotlinx.rpc.grpc.StatusException import kotlinx.rpc.grpc.client.ClientCallScope import kotlinx.rpc.grpc.client.GrpcCallOptions import kotlinx.rpc.grpc.client.GrpcClient +import kotlinx.rpc.grpc.client.plus import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.grpc.descriptor.MethodType import kotlinx.rpc.grpc.descriptor.methodType @@ -197,10 +199,15 @@ private class ClientCallScopeImpl( private fun doCall(request: Flow): Flow = flow { coroutineScope { - val call = client.channel.platformApi.createCall(method, callOptions) + // attach all call credentials set in the client to the call option ones. + callOptions.callCredentials += client.callCredentials + // pass this scope's context, so the suspend functions of call credentials launch in the same context. + // it will ensure that getRequestMetadata of the callCredential won't orphan if the call is cancelled + // by the user or by grpc-java + val call = client.channel.platformApi.createCall(method, callOptions, this.coroutineContext) /* - * We maintain a buffer of size 1 so onMessage never has to block: it only gets called after + * We maintain a buffer of size 1, so onMessage never has to block: it only gets called after * we request a response from the server, which only happens when responses is empty and * there is room in the buffer. */ diff --git a/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.jvm.kt b/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.jvm.kt index ba337ef58..98578f16a 100644 --- a/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.jvm.kt +++ b/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.jvm.kt @@ -6,11 +6,10 @@ package kotlinx.rpc.grpc.client import io.grpc.CallOptions import kotlinx.rpc.grpc.GrpcCompression -import kotlinx.rpc.internal.utils.InternalRpcApi import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext -@InternalRpcApi -public fun GrpcCallOptions.toJvm(): CallOptions { +internal fun GrpcCallOptions.toJvm(coroutineContext: CoroutineContext): CallOptions { var default = CallOptions.DEFAULT if (timeout != null) { default = default.withDeadlineAfter(timeout!!.inWholeMilliseconds, TimeUnit.MILLISECONDS) @@ -18,5 +17,8 @@ public fun GrpcCallOptions.toJvm(): CallOptions { if (compression !is GrpcCompression.None) { default = default.withCompression(compression.name) } + if (callCredentials !is EmptyCallCredentials) { + default = default.withCallCredentials(callCredentials.toJvm(coroutineContext)) + } return default } \ No newline at end of file diff --git a/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/credentials.jvm.kt b/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/credentials.jvm.kt index 7849cbef6..05886c289 100644 --- a/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/credentials.jvm.kt +++ b/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/credentials.jvm.kt @@ -4,34 +4,33 @@ package kotlinx.rpc.grpc.client +import io.grpc.CallCredentials import io.grpc.ChannelCredentials import io.grpc.InsecureChannelCredentials +import io.grpc.SecurityLevel import io.grpc.TlsChannelCredentials -import kotlinx.rpc.internal.utils.InternalRpcApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.rpc.grpc.Status +import kotlinx.rpc.grpc.internal.internalError +import java.util.concurrent.Executor +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException -public actual typealias ClientCredentials = ChannelCredentials - -public actual typealias InsecureClientCredentials = InsecureChannelCredentials - -public actual typealias TlsClientCredentials = TlsChannelCredentials - -// we need a wrapper for InsecureChannelCredentials as our constructor would conflict with the private -// java constructor. -@InternalRpcApi -public actual fun createInsecureClientCredentials(): ClientCredentials { - return InsecureClientCredentials.create() -} - -internal actual fun TlsClientCredentialsBuilder(): TlsClientCredentialsBuilder = JvmTlsCLientCredentialBuilder() -internal actual fun TlsClientCredentialsBuilder.build(): ClientCredentials { - return (this as JvmTlsCLientCredentialBuilder).build() +internal fun GrpcClientCredentials.toJvm(): ChannelCredentials { + return when (this) { + is GrpcCombinedClientCredentials -> clientCredentials.toJvm() + is GrpcInsecureClientCredentials -> InsecureChannelCredentials.create() + is GrpcTlsClientCredentials -> JvmTlsCLientCredentialBuilder().apply(configure).build() + else -> internalError("Unknown client credentials type: $this") + } } -private class JvmTlsCLientCredentialBuilder : TlsClientCredentialsBuilder { - private var cb = TlsClientCredentials.newBuilder() +private class JvmTlsCLientCredentialBuilder : GrpcTlsClientCredentialsBuilder { + private var cb = TlsChannelCredentials.newBuilder() - override fun trustManager(rootCertsPem: String): TlsClientCredentialsBuilder { + override fun trustManager(rootCertsPem: String): GrpcTlsClientCredentialsBuilder { cb.trustManager(rootCertsPem.byteInputStream()) return this } @@ -39,12 +38,44 @@ private class JvmTlsCLientCredentialBuilder : TlsClientCredentialsBuilder { override fun keyManager( certChainPem: String, privateKeyPem: String, - ): TlsClientCredentialsBuilder { + ): GrpcTlsClientCredentialsBuilder { cb.keyManager(certChainPem.byteInputStream(), privateKeyPem.byteInputStream()) return this } - fun build(): ClientCredentials { + fun build(): ChannelCredentials { return cb.build() } } + +internal fun GrpcCallCredentials.toJvm(coroutineContext: CoroutineContext): CallCredentials { + return object : CallCredentials() { + override fun applyRequestMetadata( + requestInfo: RequestInfo, + appExecutor: Executor, + applier: MetadataApplier + ) { + CoroutineScope(coroutineContext).launch { + if (requiresTransportSecurity && requestInfo.securityLevel == SecurityLevel.NONE) { + applier.fail(Status.UNAUTHENTICATED.withDescription( + "Established channel does not have a sufficient security level to transfer call credential." + )) + return@launch + } + + try { + val context = GrpcCallCredentials.Context(requestInfo.authority, requestInfo.methodDescriptor.fullMethodName) + val metadata = context.getRequestMetadata() + applier.apply(metadata) + } catch (err: Throwable) { + // we are not treating StatusExceptions separately, as currently there is no + // clean way to support the same feature on native. So for the sake of similar behavior, + // we always fail with Status.UNAVAILABLE. (KRPC-233) + val description = "Getting metadata from call credentials failed with error: ${err.message}" + applier.fail(Status.UNAVAILABLE.withDescription(description).withCause(err)) + if (err is CancellationException) throw err + } + } + } + } +} diff --git a/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.jvm.kt b/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.jvm.kt index b5e1b016d..4da2a9dbc 100644 --- a/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.jvm.kt +++ b/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.jvm.kt @@ -9,6 +9,7 @@ import kotlinx.rpc.grpc.client.GrpcCallOptions import kotlinx.rpc.grpc.client.toJvm import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.internal.utils.InternalRpcApi +import kotlin.coroutines.CoroutineContext @InternalRpcApi public actual typealias GrpcChannel = Channel @@ -17,6 +18,7 @@ public actual typealias GrpcChannel = Channel public actual fun GrpcChannel.createCall( methodDescriptor: MethodDescriptor, callOptions: GrpcCallOptions, + coroutineContext: CoroutineContext, ): ClientCall { - return this.newCall(methodDescriptor, callOptions.toJvm()) + return this.newCall(methodDescriptor, callOptions.toJvm(coroutineContext)) } \ No newline at end of file diff --git a/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.jvm.kt b/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.jvm.kt index 509f467bb..5e210faa0 100644 --- a/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.jvm.kt +++ b/grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.jvm.kt @@ -9,8 +9,9 @@ package kotlinx.rpc.grpc.client.internal import io.grpc.Grpc import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.rpc.grpc.client.ClientCredentials import kotlinx.rpc.grpc.client.GrpcClientConfiguration +import kotlinx.rpc.grpc.client.GrpcClientCredentials +import kotlinx.rpc.grpc.client.toJvm import kotlinx.rpc.internal.utils.InternalRpcApi import java.util.concurrent.TimeUnit import kotlin.time.Duration @@ -36,18 +37,18 @@ public actual fun ManagedChannelBuilder<*>.buildChannel(): ManagedChannel { public actual fun ManagedChannelBuilder( hostname: String, port: Int, - credentials: ClientCredentials?, + credentials: GrpcClientCredentials?, ): ManagedChannelBuilder<*> { - if (credentials != null) return Grpc.newChannelBuilderForAddress(hostname, port, credentials) + if (credentials != null) return Grpc.newChannelBuilderForAddress(hostname, port, credentials.toJvm()) return io.grpc.ManagedChannelBuilder.forAddress(hostname, port) } @InternalRpcApi public actual fun ManagedChannelBuilder( target: String, - credentials: ClientCredentials?, + credentials: GrpcClientCredentials?, ): ManagedChannelBuilder<*> { - if (credentials != null) return Grpc.newChannelBuilder(target, credentials) + if (credentials != null) return Grpc.newChannelBuilder(target, credentials.toJvm()) return io.grpc.ManagedChannelBuilder.forTarget(target) } diff --git a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallCredentials.native.kt b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallCredentials.native.kt new file mode 100644 index 000000000..56ad87de7 --- /dev/null +++ b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallCredentials.native.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalForeignApi::class) + +package kotlinx.rpc.grpc.client + +import cnames.structs.grpc_call_credentials +import kotlinx.cinterop.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.rpc.grpc.GrpcMetadata +import kotlinx.rpc.grpc.StatusException +import kotlinx.rpc.grpc.internal.destroyEntries +import kotlinx.rpc.grpc.internal.toRaw +import kotlinx.rpc.grpc.statusCode +import libkgrpc.* +import platform.posix.size_tVar +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +// Stable reference holder for Kotlin objects +private class CredentialsPluginState( + val kotlinCreds: GrpcCallCredentials, + val coroutineContext: CoroutineContext, +) + +private fun getMetadataCallback( + state: COpaquePointer?, + context: CValue, + cb: grpc_credentials_plugin_metadata_cb?, + userData: COpaquePointer?, + credsMd: CPointer?, + numCredMd: CPointer?, + status: CPointer?, + errorDetails: CPointer>? +): Int { + val pluginState = state!!.asStableRef().get() + + fun notifyResult(metadata: GrpcMetadata, status: grpc_status_code, errorDetails: String?) { + memScoped { + // Convert GrpcMetadata to grpc_metadata array + val metadataArray = with(metadata) { + this@memScoped.allocRawGrpcMetadata() + } + + try { + // Invoke the callback with success + cb?.invoke( + userData, + metadataArray.metadata, + metadataArray.count, + status, + errorDetails?.cstr?.ptr + ) + } finally { + metadataArray.destroyEntries() + } + } + } + + val scope = CoroutineScope(pluginState.coroutineContext) + + // Launch coroutine to call a suspend function asynchronously + scope.launch { + // Extract context information + val serviceUrl = context.useContents { service_url?.toKString() ?: "" } + val methodName = context.useContents { method_name?.toKString() ?: "" } + val authority = extractAuthority(serviceUrl) + val serviceFq = serviceUrl.removeUntil("$authority/") + + // Create Kotlin context + val kotlinContext = GrpcCallCredentials.Context( + authority = authority, + methodName = "$serviceFq/$methodName" + ) + + var metadata = GrpcMetadata() + try { + // Call the Kotlin suspend function + metadata = with(pluginState.kotlinCreds) { + kotlinContext.getRequestMetadata() + } + notifyResult(metadata, grpc_status_code.GRPC_STATUS_OK, null) + } catch (e: StatusException) { + notifyResult(metadata, e.getStatus().statusCode.toRaw(), e.message) + } catch (e: CancellationException) { + notifyResult(metadata, grpc_status_code.GRPC_STATUS_CANCELLED, e.message) + throw e + } catch (e: Exception) { + notifyResult(metadata, grpc_status_code.GRPC_STATUS_UNAVAILABLE, e.message) + } + } + + // Return 0 to indicate asynchronous processing + return 0 +} + +private fun debugStringCallback(state: COpaquePointer?): CPointer? { + return gpr_strdup("KotlinCallCredentials") +} + +private fun extractAuthority(serviceUrl: String): String { + // service_url format: "://example.com:443/with.package.service" + return serviceUrl + .removeUntil("://") + .substringBefore("/") +} + +private fun String.removeUntil(pattern: String): String { + val idx = indexOf(pattern) + return if (idx == -1) this else removeRange(0, idx + pattern.length) +} + +private fun destroyCallback(state: COpaquePointer?) { + state?.asStableRef()?.dispose() +} + +internal fun GrpcCallCredentials.createRaw( + coroutineContext: CoroutineContext, +): CPointer? = memScoped { + // Create a stable reference to keep the Kotlin object alive + val pluginState = CredentialsPluginState(this@createRaw, coroutineContext) + val stableRef = StableRef.create(pluginState) + + // Create plugin structure + val plugin = alloc { + get_metadata = staticCFunction(::getMetadataCallback) + debug_string = staticCFunction(::debugStringCallback) + destroy = staticCFunction(::destroyCallback) + state = stableRef.asCPointer() + type = "kgrpc_call_credentials".cstr.ptr + } + + // Determine security level + val minSecurityLevel = if (this@createRaw.requiresTransportSecurity) { + GRPC_PRIVACY_AND_INTEGRITY + } else { + GRPC_SECURITY_NONE + } + + // Create and return credentials + grpc_metadata_credentials_create_from_plugin( + plugin.readValue(), + minSecurityLevel, + null + ) +} \ No newline at end of file diff --git a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/credentials.native.kt b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/credentials.native.kt index 9b1898133..7f7f37d92 100644 --- a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/credentials.native.kt +++ b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/credentials.native.kt @@ -9,48 +9,34 @@ package kotlinx.rpc.grpc.client import cnames.structs.grpc_channel_credentials import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job import kotlinx.rpc.grpc.internal.TlsCredentialsOptionsBuilder +import kotlinx.rpc.grpc.internal.internalError import kotlinx.rpc.internal.utils.InternalRpcApi +import libkgrpc.grpc_call_credentials_release import libkgrpc.grpc_channel_credentials_release +import libkgrpc.grpc_composite_channel_credentials_create import libkgrpc.grpc_insecure_credentials_create import libkgrpc.grpc_tls_credentials_create import libkgrpc.grpc_tls_credentials_options_destroy import kotlin.experimental.ExperimentalNativeApi -import kotlin.native.ref.createCleaner -public actual abstract class ClientCredentials internal constructor( - internal val raw: CPointer, -) { - @Suppress("unused") - internal val rawCleaner = createCleaner(raw) { - grpc_channel_credentials_release(it) +internal fun GrpcClientCredentials.createRaw(): CPointer { + return when (this) { + is GrpcCombinedClientCredentials -> + // we don't create a composite credential, as we collect them and apply them on every call + clientCredentials.createRaw() + is GrpcInsecureClientCredentials -> grpc_insecure_credentials_create() ?: error("grpc_insecure_credentials_create() returned null") + is GrpcTlsClientCredentials -> NativeTlsClientCredentialsBuilder().apply(configure).build() + else -> internalError("Unknown client credentials type: $this") } } -public actual class InsecureClientCredentials internal constructor( - raw: CPointer, -) : ClientCredentials(raw) - -public actual class TlsClientCredentials internal constructor( - raw: CPointer, -) : ClientCredentials(raw) - -@InternalRpcApi -public actual fun createInsecureClientCredentials(): ClientCredentials { - return InsecureClientCredentials( - grpc_insecure_credentials_create() ?: error("grpc_insecure_credentials_create() returned null") - ) -} - -internal actual fun TlsClientCredentialsBuilder(): TlsClientCredentialsBuilder = NativeTlsClientCredentialsBuilder() -internal actual fun TlsClientCredentialsBuilder.build(): ClientCredentials { - return (this as NativeTlsClientCredentialsBuilder).build() -} - -private class NativeTlsClientCredentialsBuilder : TlsClientCredentialsBuilder { +internal class NativeTlsClientCredentialsBuilder : GrpcTlsClientCredentialsBuilder { var optionsBuilder = TlsCredentialsOptionsBuilder() - override fun trustManager(rootCertsPem: String): TlsClientCredentialsBuilder { + override fun trustManager(rootCertsPem: String): GrpcTlsClientCredentialsBuilder { optionsBuilder.trustManager(rootCertsPem) return this } @@ -58,18 +44,17 @@ private class NativeTlsClientCredentialsBuilder : TlsClientCredentialsBuilder { override fun keyManager( certChainPem: String, privateKeyPem: String, - ): TlsClientCredentialsBuilder { + ): GrpcTlsClientCredentialsBuilder { optionsBuilder.keyManager(certChainPem, privateKeyPem) return this } - fun build(): ClientCredentials { + fun build(): CPointer { val opts = optionsBuilder.build() - val creds = grpc_tls_credentials_create(opts) + return grpc_tls_credentials_create(opts) ?: run { grpc_tls_credentials_options_destroy(opts); error("TLS channel credential creation failed") } - return TlsClientCredentials(creds) } } diff --git a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.native.kt b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.native.kt index d14356296..0cad9820a 100644 --- a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.native.kt +++ b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/GrpcChannel.native.kt @@ -4,15 +4,19 @@ package kotlinx.rpc.grpc.client.internal +import kotlinx.coroutines.CoroutineScope +import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.client.GrpcCallOptions import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.internal.utils.InternalRpcApi +import kotlin.coroutines.CoroutineContext @InternalRpcApi public actual abstract class GrpcChannel { public abstract fun newCall( methodDescriptor: MethodDescriptor, callOptions: GrpcCallOptions, + coroutineContext: CoroutineContext ): ClientCall } @@ -20,6 +24,7 @@ public actual abstract class GrpcChannel { public actual fun GrpcChannel.createCall( methodDescriptor: MethodDescriptor, callOptions: GrpcCallOptions, + coroutineContext: CoroutineContext, ): ClientCall { - return this.newCall(methodDescriptor, callOptions) + return this.newCall(methodDescriptor, callOptions, coroutineContext) } \ No newline at end of file diff --git a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.native.kt b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.native.kt index baad4661d..5fa39eea5 100644 --- a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.native.kt +++ b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/ManagedChannel.native.kt @@ -3,12 +3,14 @@ */ @file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@file:OptIn(ExperimentalForeignApi::class) package kotlinx.rpc.grpc.client.internal -import kotlinx.rpc.grpc.client.ClientCredentials +import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.rpc.grpc.client.GrpcClientConfiguration -import kotlinx.rpc.grpc.client.TlsClientCredentials +import kotlinx.rpc.grpc.client.GrpcClientCredentials +import kotlinx.rpc.grpc.client.GrpcTlsClientCredentials import kotlinx.rpc.grpc.internal.internalError import kotlinx.rpc.internal.utils.InternalRpcApi @@ -28,7 +30,7 @@ public actual abstract class ManagedChannelBuilder> internal class NativeManagedChannelBuilder( private val target: String, - private var credentials: Lazy, + private val credentials: Lazy, ) : ManagedChannelBuilder() { fun buildChannel(): NativeManagedChannel { val keepAlive = config?.keepAlive @@ -36,11 +38,12 @@ internal class NativeManagedChannelBuilder( require(time.isPositive()) { "keepalive time must be positive" } require(timeout.isPositive()) { "keepalive timeout must be positive" } } + return NativeManagedChannel( target, - authority = config?.overrideAuthority, + overrideAuthority = config?.overrideAuthority, keepAlive = config?.keepAlive, - credentials = credentials.value, + clientCredentials = credentials.value, ) } @@ -56,15 +59,15 @@ public actual fun ManagedChannelBuilder<*>.buildChannel(): ManagedChannel { public actual fun ManagedChannelBuilder( hostname: String, port: Int, - credentials: ClientCredentials?, + credentials: GrpcClientCredentials?, ): ManagedChannelBuilder<*> { - val credentials = if (credentials == null) lazy { TlsClientCredentials() } else lazy { credentials } + val credentials = if (credentials == null) lazy { GrpcTlsClientCredentials() } else lazy { credentials } return NativeManagedChannelBuilder(target = "$hostname:$port", credentials) } @InternalRpcApi -public actual fun ManagedChannelBuilder(target: String, credentials: ClientCredentials?): ManagedChannelBuilder<*> { - val credentials = if (credentials == null) lazy { TlsClientCredentials() } else lazy { credentials } +public actual fun ManagedChannelBuilder(target: String, credentials: GrpcClientCredentials?): ManagedChannelBuilder<*> { + val credentials = if (credentials == null) lazy { GrpcTlsClientCredentials() } else lazy { credentials } return NativeManagedChannelBuilder(target, credentials) } diff --git a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeClientCall.kt b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeClientCall.kt index cfc95970d..5228a0ef6 100644 --- a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeClientCall.kt +++ b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeClientCall.kt @@ -23,6 +23,7 @@ import kotlinx.cinterop.toKString import kotlinx.cinterop.value import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.CoroutineScope import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode @@ -38,7 +39,9 @@ import kotlinx.rpc.grpc.internal.toKotlin import kotlinx.rpc.protobuf.input.stream.asInputStream import kotlinx.rpc.protobuf.input.stream.asSource import kotlinx.rpc.grpc.GrpcCompression +import kotlinx.rpc.grpc.client.EmptyCallCredentials import kotlinx.rpc.grpc.client.GrpcCallOptions +import kotlinx.rpc.grpc.client.createRaw import libkgrpc.GRPC_OP_RECV_INITIAL_METADATA import libkgrpc.GRPC_OP_RECV_MESSAGE import libkgrpc.GRPC_OP_RECV_STATUS_ON_CLIENT @@ -49,7 +52,9 @@ import libkgrpc.gpr_free import libkgrpc.grpc_byte_buffer import libkgrpc.grpc_byte_buffer_destroy import libkgrpc.grpc_call_cancel_with_status +import libkgrpc.grpc_call_credentials_release import libkgrpc.grpc_call_error +import libkgrpc.grpc_call_set_credentials import libkgrpc.grpc_call_unref import libkgrpc.grpc_metadata_array import libkgrpc.grpc_metadata_array_destroy @@ -58,6 +63,7 @@ import libkgrpc.grpc_op import libkgrpc.grpc_slice import libkgrpc.grpc_slice_unref import libkgrpc.grpc_status_code +import kotlin.coroutines.CoroutineContext import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner @@ -68,6 +74,7 @@ internal class NativeClientCall( private val methodDescriptor: MethodDescriptor, private val callOptions: GrpcCallOptions, private val callJob: CompletableJob, + private val coroutineContext: CoroutineContext, ) : ClientCall() { @Suppress("unused") @@ -75,6 +82,15 @@ internal class NativeClientCall( grpc_call_unref(it) } + private val rawCallCredentials = callOptions.callCredentials.let { + if (it is EmptyCallCredentials) null else it.createRaw(coroutineContext) + } + + @Suppress("unused") + private val rawCallCredentialsCleaner = createCleaner(rawCallCredentials) { + if (it != null) grpc_call_credentials_release(it) + } + init { // cancel the call if the job is canceled. callJob.invokeOnCompletion { @@ -181,12 +197,16 @@ internal class NativeClientCall( listener = responseListener + // attach call credentials to the call. + if (rawCallCredentials != null) { + grpc_call_set_credentials(raw, rawCallCredentials) + } + // start receiving the status from the completion queue, // which is bound to the lifetime of the call. val success = startRecvStatus() if (!success) return - // send and receive initial headers to/from the server sendAndReceiveInitialMetadata(headers) } @@ -473,4 +493,5 @@ internal class NativeClientCall( cancel(cancelMsg, e) } } + } diff --git a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeManagedChannel.kt b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeManagedChannel.kt index fad1b9371..3e29ef8f1 100644 --- a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeManagedChannel.kt +++ b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeManagedChannel.kt @@ -7,6 +7,7 @@ package kotlinx.rpc.grpc.client.internal import cnames.structs.grpc_channel +import cnames.structs.grpc_channel_credentials import kotlinx.atomicfu.atomic import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi @@ -22,9 +23,10 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.rpc.grpc.client.ClientCredentials import kotlinx.rpc.grpc.client.GrpcCallOptions import kotlinx.rpc.grpc.client.GrpcClientConfiguration +import kotlinx.rpc.grpc.client.GrpcClientCredentials +import kotlinx.rpc.grpc.client.createRaw import kotlinx.rpc.grpc.client.rawDeadline import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.grpc.internal.CompletionQueue @@ -37,8 +39,10 @@ import libkgrpc.grpc_arg_type import libkgrpc.grpc_channel_args import libkgrpc.grpc_channel_create import libkgrpc.grpc_channel_create_call +import libkgrpc.grpc_channel_credentials_release import libkgrpc.grpc_channel_destroy import libkgrpc.grpc_slice_unref +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner @@ -49,14 +53,14 @@ import kotlin.time.Duration * Native implementation of [ManagedChannel]. * * @param target The target address to connect to. - * @param credentials The credentials to use for the connection. + * @param rawChannelCredentials The credentials to use for the connection. */ internal class NativeManagedChannel( target: String, - val authority: String?, + val overrideAuthority: String?, val keepAlive: GrpcClientConfiguration.KeepAlive?, - // we must store them, otherwise the credentials are getting released - credentials: ClientCredentials, + // this is not a composite channel credentials + clientCredentials: GrpcClientCredentials, ) : ManagedChannel, ManagedChannelPlatform() { // a reference to make sure the grpc_init() was called. (it is released after shutdown) @@ -70,10 +74,12 @@ internal class NativeManagedChannel( // the channel's completion queue, handling all request operations private val cq = CompletionQueue() + private val rawChannelCredentials: CPointer = clientCredentials.createRaw() + internal val raw: CPointer = memScoped { val args = mutableListOf() - authority?.let { + overrideAuthority?.let { // the C Core API doesn't have a way to override the authority (used for TLS SNI) as it // is available in the Java gRPC implementation. // instead, it can be done by setting the "grpc.ssl_target_name_override" argument. @@ -100,7 +106,7 @@ internal class NativeManagedChannel( var rawArgs = if (args.isNotEmpty()) args.toRaw(this) else null - grpc_channel_create(target, credentials.raw, rawArgs?.ptr) + grpc_channel_create(target, rawChannelCredentials, rawArgs?.ptr) ?: error("Failed to create channel") } @@ -108,6 +114,10 @@ internal class NativeManagedChannel( private val rawCleaner = createCleaner(raw) { grpc_channel_destroy(it) } + @Suppress("unused") + internal val rawCredentialsCleaner = createCleaner(rawChannelCredentials) { + grpc_channel_credentials_release(it) + } override val platformApi: ManagedChannelPlatform = this @@ -160,11 +170,10 @@ internal class NativeManagedChannel( override fun newCall( methodDescriptor: MethodDescriptor, callOptions: GrpcCallOptions, + coroutineContext: CoroutineContext ): ClientCall { check(!isShutdown) { internalError("Channel is shutdown") } - val callJob = Job(callJobSupervisor) - val methodFullName = methodDescriptor.getFullMethodName() // to construct a valid HTTP/2 path, we must prepend the name with a slash. // the user does not do this to align it with the java implementation. @@ -184,7 +193,12 @@ internal class NativeManagedChannel( grpc_slice_unref(methodNameSlice) return NativeClientCall( - cq, rawCall, methodDescriptor, callOptions, callJob + cq = cq, + raw = rawCall, + methodDescriptor =methodDescriptor, + callOptions = callOptions, + callJob = Job(callJobSupervisor), + coroutineContext = coroutineContext, ) } diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt index 9ed2f1bc7..f3f8f3a89 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt @@ -51,6 +51,16 @@ import kotlinx.rpc.grpc.codec.MessageCodec @Suppress("RedundantConstructorKeyword") public expect class GrpcMetadata constructor() +/** + * Constructs and configures a new [GrpcMetadata] instance. + * The provided [block] is executed to apply custom modifications to the metadata object. + * + * @param block A lambda function allowing customization of the [GrpcMetadata] object. + * The lambda operates on the metadata instance being built. + * @return The configured [GrpcMetadata] instance. + */ +public fun buildGrpcMetadata(block: GrpcMetadata.() -> Unit): GrpcMetadata = GrpcMetadata().apply(block) + /** * A typed key for metadata entries that uses a [MessageCodec] to encode and decode values. * diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt new file mode 100644 index 000000000..cb1467701 --- /dev/null +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.grpc.internal + +import kotlinx.rpc.internal.utils.InternalRpcApi + +@InternalRpcApi +public fun internalError(message: String): Nothing { + error("Unexpected internal error: $message. Please, report the issue here: https://github.com/Kotlin/kotlinx-rpc/issues/new?template=bug_report.md") +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt index d24d197f9..e9f34ae48 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/CoreClientTest.kt @@ -4,6 +4,8 @@ package kotlinx.rpc.grpc.test import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -11,9 +13,9 @@ import kotlinx.coroutines.withTimeout import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode -import kotlinx.rpc.grpc.client.createInsecureClientCredentials import kotlinx.rpc.grpc.client.internal.ClientCall import kotlinx.rpc.grpc.client.GrpcCallOptions +import kotlinx.rpc.grpc.client.GrpcInsecureClientCredentials import kotlinx.rpc.grpc.client.internal.ManagedChannel import kotlinx.rpc.grpc.client.internal.ManagedChannelBuilder import kotlinx.rpc.grpc.client.internal.buildChannel @@ -52,11 +54,11 @@ class GrpcCoreClientTest { ) private fun ManagedChannel.newHelloCall(fullName: String = "kotlinx.rpc.grpc.test.GreeterService/SayHello"): ClientCall = - platformApi.createCall(descriptorFor(fullName), GrpcCallOptions()) + platformApi.createCall(descriptorFor(fullName), GrpcCallOptions(), Dispatchers.Default) private fun createChannel(): ManagedChannel = ManagedChannelBuilder( target = "localhost:$PORT", - credentials = createInsecureClientCredentials() + credentials = GrpcInsecureClientCredentials() ).buildChannel() diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcCallCredentialsTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcCallCredentialsTest.kt new file mode 100644 index 000000000..af5d32ee7 --- /dev/null +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcCallCredentialsTest.kt @@ -0,0 +1,365 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.grpc.test.proto + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.rpc.RpcServer +import kotlinx.rpc.grpc.GrpcMetadata +import kotlinx.rpc.grpc.Status +import kotlinx.rpc.grpc.StatusCode +import kotlinx.rpc.grpc.StatusException +import kotlinx.rpc.grpc.append +import kotlinx.rpc.grpc.buildGrpcMetadata +import kotlinx.rpc.grpc.client.GrpcCallCredentials +import kotlinx.rpc.grpc.client.GrpcCallCredentials.Context +import kotlinx.rpc.grpc.client.GrpcClient +import kotlinx.rpc.grpc.client.GrpcTlsClientCredentials +import kotlinx.rpc.grpc.client.plus +import kotlinx.rpc.grpc.getAll +import kotlinx.rpc.grpc.server.TlsServerCredentials +import kotlinx.rpc.grpc.test.EchoRequest +import kotlinx.rpc.grpc.test.EchoService +import kotlinx.rpc.grpc.test.EchoServiceImpl +import kotlinx.rpc.grpc.test.SERVER_CERT_PEM +import kotlinx.rpc.grpc.test.SERVER_KEY_PEM +import kotlinx.rpc.grpc.test.assertGrpcFailure +import kotlinx.rpc.grpc.test.invoke +import kotlinx.rpc.registerService +import kotlinx.rpc.withService +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.milliseconds + +class GrpcCallCredentialsTest : GrpcProtoTest() { + override fun RpcServer.registerServices() { + return registerService { EchoServiceImpl() } + } + + private fun assertAuthorizationHeaders(metadata: GrpcMetadata?, vararg expectedTokens: String) { + assertNotNull(metadata, "Metadata should not be null") + val authHeaders = metadata.getAll("authorization") + assertNotNull(authHeaders, "Authorization headers should not be null") + assertEquals(expectedTokens.size, authHeaders.size) + expectedTokens.forEachIndexed { index, token -> + assertEquals(token, authHeaders[index]) + } + } + + @Test + fun `test simple combined call credentials - should succeed`() { + var grpcMetadata: GrpcMetadata? = null + runGrpcTest( + configure = { + credentials = plaintext() + NoTLSBearerTokenCredentials() + }, + serverInterceptors = serverInterceptor { + grpcMetadata = requestHeaders + proceed(it) + }, + test = ::unaryCall + ) + + assertAuthorizationHeaders(grpcMetadata, "Bearer token") + } + + @Test + fun `test combine multiple call credentials - should succeed`() { + var grpcMetadata: GrpcMetadata? = null + val callCreds = (NoTLSBearerTokenCredentials("token-1") + NoTLSBearerTokenCredentials("token-2")) + runGrpcTest( + configure = { + credentials = plaintext() + callCreds + }, + serverInterceptors = serverInterceptor { + grpcMetadata = requestHeaders + proceed(it) + }, + test = ::unaryCall + ) + + assertAuthorizationHeaders(grpcMetadata, "Bearer token-1", "Bearer token-2") + } + + @Test + fun `test combine three or more call credentials at config and interceptor time - should succeed`() { + var grpcMetadata: GrpcMetadata? = null + val configCallCreds = (NoTLSBearerTokenCredentials("token-1") + NoTLSBearerTokenCredentials("token-2") + NoTLSBearerTokenCredentials("token-3")) + runGrpcTest( + configure = { + credentials = plaintext() + configCallCreds + }, + clientInterceptors = clientInterceptor { + callOptions.callCredentials += NoTLSBearerTokenCredentials("token-4") + proceed(it) + }, + serverInterceptors = serverInterceptor { + grpcMetadata = requestHeaders + proceed(it) + }, + test = ::unaryCall + ) + + // 4 before 1 as callOption callCredentials are applied before client level ones + assertAuthorizationHeaders(grpcMetadata, "Bearer token-4", "Bearer token-1", "Bearer token-2", "Bearer token-3") + } + + @Test + fun `test plaintext call credentials - should fail`() { + assertGrpcFailure(StatusCode.UNAUTHENTICATED, "Established channel does not have a sufficient security level to transfer call credential.") { + runGrpcTest( + configure = { + credentials = plaintext() + TlsBearerTokenCredentials() + }, + test = ::unaryCall + ) + } + } + + @Test + fun `test tls call credentials - should succeed`() { + val serverTls = TlsServerCredentials(SERVER_CERT_PEM, SERVER_KEY_PEM) + val clientTls = GrpcTlsClientCredentials { trustManager(SERVER_CERT_PEM) } + val clientCombined = clientTls + TlsBearerTokenCredentials() + + var grpcMetadata: GrpcMetadata? = null + runGrpcTest( + configure = { + credentials = clientCombined + overrideAuthority = "foo.test.google.fr" + }, + serverCreds = serverTls, + serverInterceptors = serverInterceptor { + grpcMetadata = requestHeaders + proceed(it) + }, + test = ::unaryCall + ) + + assertAuthorizationHeaders(grpcMetadata, "Bearer token") + } + + @Test + fun `test throw status exception - should fail with status`() { + assertGrpcFailure(StatusCode.UNAVAILABLE, "This is my custom exception") { + runGrpcTest( + configure = { + credentials = plaintext() + ThrowingCallCredentials() + }, + test = ::unaryCall + ) + } + } + + @Test + fun `test throw exception - should fail`() { + assertGrpcFailure(StatusCode.UNAVAILABLE, "This is my custom exception") { + runGrpcTest( + configure = { + credentials = plaintext() + ThrowingCallCredentials(IllegalStateException("This is my custom exception")) + }, + test = ::unaryCall + ) + } + } + + @Test + fun `test interceptor call credentials - should succeed`() { + var grpcMetadata: GrpcMetadata? = null + runGrpcTest( + clientInterceptors = clientInterceptor { + callOptions.callCredentials += NoTLSBearerTokenCredentials() + proceed(it) + }, + serverInterceptors = serverInterceptor { + grpcMetadata = requestHeaders + proceed(it) + }, + test = ::unaryCall + ) + + assertAuthorizationHeaders(grpcMetadata, "Bearer token") + } + + @Test + fun `test interceptor call credentials without TLS - should fail`() { + assertGrpcFailure(StatusCode.UNAUTHENTICATED, "Established channel does not have a sufficient security level to transfer call credential.") { + runGrpcTest( + clientInterceptors = clientInterceptor { + callOptions.callCredentials += TlsBearerTokenCredentials() + proceed(it) + }, + test = ::unaryCall + )} + } + + @Test + fun `test context contains correct method descriptor - should succeed`() { + var capturedMethod: String? = null + + val contextCapturingCredentials = object : GrpcCallCredentials { + override suspend fun Context.getRequestMetadata(): GrpcMetadata { + capturedMethod = methodName + return GrpcMetadata() + } + + override val requiresTransportSecurity: Boolean = false + } + + runGrpcTest( + configure = { + credentials = plaintext() + contextCapturingCredentials + }, + test = ::unaryCall + ) + + assertEquals("kotlinx.rpc.grpc.test.EchoService/UnaryEcho", capturedMethod) + } + + @Test + fun `test context contains correct authority - should succeed`() { + var capturedAuthority: String? = null + + val contextCapturingCredentials = object : GrpcCallCredentials { + override suspend fun Context.getRequestMetadata(): GrpcMetadata { + capturedAuthority = authority + return GrpcMetadata() + } + + override val requiresTransportSecurity: Boolean = false + } + + runGrpcTest( + configure = { + credentials = plaintext() + contextCapturingCredentials + overrideAuthority = "test.example.com" + }, + test = ::unaryCall + ) + + assertEquals("test.example.com", capturedAuthority) + } + + @Test + fun `test call credentials cancellation because of timeout - should fail`() { + var callCredsCancelled = false + val slowCredentials = object : GrpcCallCredentials { + override suspend fun Context.getRequestMetadata(): GrpcMetadata { + try { + // block indefinitely to simulate a slow call credential. + // this works even in a runTest coroutine dispatcher + CompletableDeferred().await() + return buildGrpcMetadata { + append("Authentication", "Bearer token") + } + } catch (err: CancellationException) { + callCredsCancelled = true + throw err + } + } + + override val requiresTransportSecurity: Boolean + get() = false + } + assertGrpcFailure(StatusCode.DEADLINE_EXCEEDED) { + runGrpcTest( + configure = { + credentials = plaintext() + slowCredentials + }, + clientInterceptors = clientInterceptor { + callOptions.timeout = 100.milliseconds + proceed(it) + }, + test = ::unaryCall + ) + } + + // assert that the getRequestMetadata suspend method was cancelled + assertEquals(true, callCredsCancelled) + } + + @Test + fun `test call credentials should be called even if second fails - should fail`() { + var calledCredentialHandler = false + val someCredentials = object : PlaintextCallCredentials() { + override suspend fun Context.getRequestMetadata(): GrpcMetadata { + calledCredentialHandler = true + return buildGrpcMetadata { } + } + } + assertGrpcFailure(StatusCode.UNAVAILABLE) { + runGrpcTest( + configure = { + credentials = plaintext() + someCredentials + ThrowingCallCredentials() + }, + test = ::unaryCall + ) + } + + // assert that the getRequestMetadata suspend method was cancelled + assertEquals(true, calledCredentialHandler) + } + + @Test + fun `test call credentials should not be called if previous one fails - should fail`() { + var calledCredentialHandler = false + val someCredentials = object : PlaintextCallCredentials() { + override suspend fun Context.getRequestMetadata(): GrpcMetadata { + calledCredentialHandler = true + return buildGrpcMetadata { } + } + } + assertGrpcFailure(StatusCode.UNAVAILABLE) { + runGrpcTest( + configure = { + credentials = plaintext() + ThrowingCallCredentials() + someCredentials + }, + test = ::unaryCall + ) + } + + // assert that the getRequestMetadata suspend method was cancelled + assertEquals(false, calledCredentialHandler) + } +} + +private suspend fun unaryCall(grpcClient: GrpcClient) { + val service = grpcClient.withService() + val response = service.UnaryEcho(EchoRequest { message = "Echo" }) + assertEquals("Echo", response.message) +} + +abstract class PlaintextCallCredentials : GrpcCallCredentials { + override val requiresTransportSecurity: Boolean + get() = false +} + +class ThrowingCallCredentials( + private val exception: Throwable = StatusException(Status(StatusCode.UNIMPLEMENTED, "This is my custom exception")) +) : PlaintextCallCredentials() { + override suspend fun Context.getRequestMetadata(): GrpcMetadata { + throw exception + } +} + +class NoTLSBearerTokenCredentials( + val token: String = "token" +) : PlaintextCallCredentials() { + override suspend fun Context.getRequestMetadata(): GrpcMetadata { + return buildGrpcMetadata { + // potentially fetching the token from a secure storage + append("Authorization", "Bearer $token") + } + } +} + +class TlsBearerTokenCredentials : GrpcCallCredentials { + override suspend fun Context.getRequestMetadata(): GrpcMetadata { + return buildGrpcMetadata { + append("Authorization", "Bearer token") + } + } +} \ No newline at end of file diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt index a379f2d97..04ed29126 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt @@ -8,10 +8,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.test.runTest import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.client.ClientCallScope -import kotlinx.rpc.grpc.client.ClientCredentials +import kotlinx.rpc.grpc.client.GrpcClientCredentials import kotlinx.rpc.grpc.client.ClientInterceptor import kotlinx.rpc.grpc.client.GrpcClient import kotlinx.rpc.grpc.client.GrpcClientConfiguration @@ -27,13 +26,13 @@ abstract class GrpcProtoTest { fun runGrpcTest( serverCreds: ServerCredentials? = null, - clientCreds: ClientCredentials? = null, + clientCreds: GrpcClientCredentials? = null, overrideAuthority: String? = null, clientInterceptors: List = emptyList(), serverInterceptors: List = emptyList(), configure: GrpcClientConfiguration.() -> Unit = {}, test: suspend (GrpcClient) -> Unit, - ) = runTest { + ) = runBlocking { serverMutex.withLock { val grpcClient = GrpcClient("localhost", PORT) { credentials = clientCreds ?: plaintext() diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcTlsTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcTlsTest.kt index 8665bc948..5810e0df2 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcTlsTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcTlsTest.kt @@ -12,7 +12,7 @@ import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.client.GrpcClient import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.server.TlsClientAuth -import kotlinx.rpc.grpc.client.TlsClientCredentials +import kotlinx.rpc.grpc.client.GrpcTlsClientCredentials import kotlinx.rpc.grpc.server.TlsServerCredentials import kotlinx.rpc.grpc.test.CA_PEM import kotlinx.rpc.grpc.test.CLIENT_CERT_PEM @@ -55,7 +55,7 @@ class GrpcTlsTest : GrpcProtoTest() { @Test fun `test TLS with valid certificates - should succeed`() { val serverTls = TlsServerCredentials(SERVER_CERT_PEM, SERVER_KEY_PEM) - val clientTls = TlsClientCredentials { trustManager(SERVER_CERT_PEM) } + val clientTls = GrpcTlsClientCredentials { trustManager(SERVER_CERT_PEM) } runGrpcTest(serverTls, clientTls, overrideAuthority = "foo.test.google.fr", test = ::defaultUnaryTest) } @@ -66,7 +66,7 @@ class GrpcTlsTest : GrpcProtoTest() { trustManager(CA_PEM) clientAuth(TlsClientAuth.REQUIRE) } - val clientTls = TlsClientCredentials { + val clientTls = GrpcTlsClientCredentials { keyManager(CLIENT_CERT_PEM, CLIENT_KEY_PEM) trustManager(CA_PEM) } @@ -85,7 +85,7 @@ class GrpcTlsTest : GrpcProtoTest() { // clientAuth is optional, so a client without a certificate can connect clientAuth(TlsClientAuth.OPTIONAL) } - val clientTls = TlsClientCredentials { + val clientTls = GrpcTlsClientCredentials { keyManager(CLIENT_CERT_PEM, CLIENT_KEY_PEM) trustManager(CA_PEM) } @@ -101,7 +101,7 @@ class GrpcTlsTest : GrpcProtoTest() { clientAuth(TlsClientAuth.REQUIRE) } // client does NOT provide keyManager, only trusts CA - val clientTls = TlsClientCredentials { + val clientTls = GrpcTlsClientCredentials { trustManager(CA_PEM) } @@ -114,7 +114,7 @@ class GrpcTlsTest : GrpcProtoTest() { fun `test TLS with no client trustManager - should fail`() = runTest { val serverTls = TlsServerCredentials(SERVER_CERT_PEM, SERVER_KEY_PEM) // client credential doesn't contain a trustManager, so server authentication will fail - val clientTls = TlsClientCredentials {} + val clientTls = GrpcTlsClientCredentials {} assertGrpcFailure(StatusCode.UNAVAILABLE) { runGrpcTest(serverTls, clientTls, overrideAuthority = "foo.test.google.fr", test = ::defaultUnaryTest) } @@ -123,7 +123,7 @@ class GrpcTlsTest : GrpcProtoTest() { @Test fun `test TLS with invalid authority - should fail`() = runTest { val serverTls = TlsServerCredentials(SERVER_CERT_PEM, SERVER_KEY_PEM) - val clientTls = TlsClientCredentials { trustManager(CA_PEM) } + val clientTls = GrpcTlsClientCredentials { trustManager(CA_PEM) } // the authority does not match the certificate assertGrpcFailure(StatusCode.UNAVAILABLE) { runGrpcTest(serverTls, clientTls, overrideAuthority = "invalid.host.name", test = ::defaultUnaryTest) @@ -140,7 +140,7 @@ class GrpcTlsTest : GrpcProtoTest() { @Test fun `test TLS client with plaintext server - should fail`() = runTest { - val clientTls = TlsClientCredentials { trustManager(CA_PEM) } + val clientTls = GrpcTlsClientCredentials { trustManager(CA_PEM) } assertGrpcFailure(StatusCode.UNAVAILABLE) { runGrpcTest(clientCreds = clientTls, overrideAuthority = "foo.test.google.fr", test = ::defaultUnaryTest) } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/utils.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/utils.kt index af5b35492..416ee5950 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/utils.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/utils.kt @@ -15,7 +15,7 @@ fun assertGrpcFailure(statusCode: StatusCode, message: String? = null, block: () val exc = assertFailsWith(message) { block() } assertEquals(statusCode, exc.getStatus().statusCode) if (message != null) { - assertContains(message, exc.getStatus().getDescription() ?: "") + assertContains(exc.getStatus().getDescription() ?: "", message) } } diff --git a/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def b/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def index c86979d6f..c7d336302 100644 --- a/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def +++ b/grpc/grpc-core/src/nativeInterop/cinterop/libkgrpc.def @@ -1,9 +1,9 @@ headers = kgrpc.h grpc/grpc.h grpc/credentials.h grpc/byte_buffer_reader.h \ - grpc/support/alloc.h grpc/impl/propagation_bits.h + grpc/support/alloc.h grpc/impl/propagation_bits.h grpc/support/string_util.h headerFilter= kgrpc.h grpc/slice.h grpc/byte_buffer.h grpc/grpc.h \ grpc/impl/grpc_types.h grpc/credentials.h grpc/support/time.h grpc/byte_buffer_reader.h \ - grpc/support/alloc.h grpc/impl/propagation_bits.h + grpc/support/alloc.h grpc/impl/propagation_bits.h grpc/support/string_util.h noStringConversion = grpc_slice_from_copied_buffer my_grpc_slice_from_copied_buffer strictEnums = grpc_status_code grpc_connectivity_state grpc_call_error diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.native.kt similarity index 97% rename from grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt rename to grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.native.kt index fa4d76b41..e38cced0c 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.native.kt @@ -47,11 +47,6 @@ import libkgrpc.grpc_slice_unref import libkgrpc.grpc_status_code import platform.posix.memcpy -@InternalRpcApi -public fun internalError(message: String): Nothing { - error("Unexpected internal error: $message. Please, report the issue here: https://github.com/Kotlin/kotlinx-rpc/issues/new?template=bug_report.md") -} - @InternalRpcApi @OptIn(ExperimentalForeignApi::class, InternalIoApi::class, UnsafeIoApi::class)