Skip to content

Commit 4c967e0

Browse files
committed
grpc: CallCredentials implementation on JVM
1 parent b426394 commit 4c967e0

File tree

7 files changed

+304
-0
lines changed

7 files changed

+304
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.rpc.grpc.client
6+
7+
import kotlinx.rpc.grpc.GrpcMetadata
8+
9+
public interface GrpcCallCredentials {
10+
11+
public suspend fun GrpcMetadata.applyOnMetadata(callOptions: GrpcCallOptions)
12+
13+
public val requiresTransportSecurity: Boolean
14+
get() = true
15+
}
16+
17+
public operator fun GrpcCallCredentials.plus(other: GrpcCallCredentials): GrpcCallCredentials {
18+
return CombinedCallCredentials(this, other)
19+
}
20+
21+
public fun GrpcCallCredentials.combine(other: GrpcCallCredentials): GrpcCallCredentials = this + other
22+
23+
public object EmptyCallCredentials : GrpcCallCredentials {
24+
override suspend fun GrpcMetadata.applyOnMetadata(callOptions: GrpcCallOptions) {
25+
// do nothing
26+
}
27+
override val requiresTransportSecurity: Boolean = false
28+
}
29+
30+
internal class CombinedCallCredentials(
31+
private val first: GrpcCallCredentials,
32+
private val second: GrpcCallCredentials
33+
) : GrpcCallCredentials {
34+
override suspend fun GrpcMetadata.applyOnMetadata(
35+
callOptions: GrpcCallOptions
36+
) {
37+
with(first) {
38+
this@applyOnMetadata.applyOnMetadata(callOptions)
39+
}
40+
with(second) {
41+
this@applyOnMetadata.applyOnMetadata(callOptions)
42+
}
43+
}
44+
45+
override val requiresTransportSecurity: Boolean = first.requiresTransportSecurity || second.requiresTransportSecurity
46+
47+
}

grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,6 @@ public class GrpcCallOptions {
4949
* @see GrpcCompression
5050
*/
5151
public var compression: GrpcCompression = GrpcCompression.None
52+
53+
public var callCredentials: GrpcCallCredentials = EmptyCallCredentials
5254
}

grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/credentials.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import kotlinx.rpc.internal.utils.InternalRpcApi
88

99
public expect abstract class ClientCredentials
1010

11+
public expect operator fun ClientCredentials.plus(other: GrpcCallCredentials): ClientCredentials
12+
13+
public fun ClientCredentials.combine(other: GrpcCallCredentials): ClientCredentials = this + other
14+
15+
1116
public expect class InsecureClientCredentials : ClientCredentials
1217

1318
// we need a wrapper for InsecureChannelCredentials as our constructor would conflict with the private

grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/GrpcCallOptions.jvm.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,8 @@ public fun GrpcCallOptions.toJvm(): CallOptions {
1818
if (compression !is GrpcCompression.None) {
1919
default = default.withCompression(compression.name)
2020
}
21+
if (callCredentials !is EmptyCallCredentials) {
22+
default = default.withCallCredentials(callCredentials.toJvm())
23+
}
2124
return default
2225
}

grpc/grpc-client/src/jvmMain/kotlin/kotlinx/rpc/grpc/client/credentials.jvm.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@
44

55
package kotlinx.rpc.grpc.client
66

7+
import io.grpc.CallCredentials
78
import io.grpc.ChannelCredentials
9+
import io.grpc.CompositeChannelCredentials
810
import io.grpc.InsecureChannelCredentials
11+
import io.grpc.Metadata
12+
import io.grpc.SecurityLevel
913
import io.grpc.TlsChannelCredentials
14+
import kotlinx.coroutines.CoroutineScope
15+
import kotlinx.coroutines.asCoroutineDispatcher
16+
import kotlinx.coroutines.launch
17+
import kotlinx.rpc.grpc.Status
18+
import kotlinx.rpc.grpc.StatusException
1019
import kotlinx.rpc.internal.utils.InternalRpcApi
20+
import java.util.concurrent.Executor
1121

1222
public actual typealias ClientCredentials = ChannelCredentials
1323

@@ -48,3 +58,34 @@ private class JvmTlsCLientCredentialBuilder : TlsClientCredentialsBuilder {
4858
return cb.build()
4959
}
5060
}
61+
62+
internal fun GrpcCallCredentials.toJvm(): CallCredentials {
63+
return object : CallCredentials() {
64+
override fun applyRequestMetadata(
65+
requestInfo: RequestInfo,
66+
appExecutor: Executor,
67+
applier: MetadataApplier
68+
) {
69+
val dispatcher = appExecutor.asCoroutineDispatcher()
70+
CoroutineScope(dispatcher).launch {
71+
try {
72+
check(!requiresTransportSecurity || requestInfo.securityLevel != SecurityLevel.NONE) {
73+
"Transport security required but not present"
74+
}
75+
76+
val metadata = Metadata()
77+
metadata.applyOnMetadata(GrpcCallOptions(/* populate from requestInfo if needed */))
78+
applier.apply(metadata)
79+
} catch (e: StatusException) {
80+
applier.fail(e.status)
81+
} catch (e: Exception) {
82+
applier.fail(Status.UNAUTHENTICATED.withCause(e))
83+
}
84+
}
85+
}
86+
}
87+
}
88+
89+
public actual operator fun ClientCredentials.plus(other: GrpcCallCredentials): ClientCredentials {
90+
return CompositeChannelCredentials.create(this, other.toJvm())
91+
}

grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/credentials.native.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,10 @@ private class NativeTlsClientCredentialsBuilder : TlsClientCredentialsBuilder {
7373
return TlsClientCredentials(creds)
7474
}
7575
}
76+
77+
public actual operator fun ClientCredentials.plus(other: GrpcCallCredentials): ClientCredentials {
78+
TODO("Not yet implemented")
79+
}
80+
81+
internal actual val ClientCredentials.callCredentials: GrpcCallCredentials?
82+
get() = TODO("Not yet implemented")
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.rpc.grpc.test.proto
6+
7+
import kotlinx.rpc.RpcServer
8+
import kotlinx.rpc.grpc.GrpcMetadata
9+
import kotlinx.rpc.grpc.Status
10+
import kotlinx.rpc.grpc.StatusCode
11+
import kotlinx.rpc.grpc.StatusException
12+
import kotlinx.rpc.grpc.append
13+
import kotlinx.rpc.grpc.client.GrpcCallCredentials
14+
import kotlinx.rpc.grpc.client.GrpcCallOptions
15+
import kotlinx.rpc.grpc.client.GrpcClient
16+
import kotlinx.rpc.grpc.client.TlsClientCredentials
17+
import kotlinx.rpc.grpc.client.plus
18+
import kotlinx.rpc.grpc.getAll
19+
import kotlinx.rpc.grpc.server.TlsServerCredentials
20+
import kotlinx.rpc.grpc.test.EchoRequest
21+
import kotlinx.rpc.grpc.test.EchoService
22+
import kotlinx.rpc.grpc.test.EchoServiceImpl
23+
import kotlinx.rpc.grpc.test.SERVER_CERT_PEM
24+
import kotlinx.rpc.grpc.test.SERVER_KEY_PEM
25+
import kotlinx.rpc.grpc.test.assertGrpcFailure
26+
import kotlinx.rpc.grpc.test.invoke
27+
import kotlinx.rpc.registerService
28+
import kotlinx.rpc.withService
29+
import kotlin.test.Test
30+
import kotlin.test.assertEquals
31+
32+
/**
33+
* Tests that the client can configure the compression of requests.
34+
*
35+
* This test is hard to realize on native, as the gRPC-Core doesn't expose internal headers like
36+
* `grpc-encoding` to the user application. This means we cannot verify that the client or sever
37+
* actually sent those headers on native. Instead, we capture the grpc trace output (written to stderr)
38+
* and verify that the client and server actually used the compression algorithm.
39+
*/
40+
class GrpcCallCredentialsTest : GrpcProtoTest() {
41+
override fun RpcServer.registerServices() {
42+
return registerService<EchoService> { EchoServiceImpl() }
43+
}
44+
45+
@Test
46+
fun `test simple combined call credentials - should succeed`() {
47+
var grpcMetadata: GrpcMetadata? = null
48+
runGrpcTest(
49+
configure = {
50+
credentials = plaintext() + NoTLSBearerTokenCredentials()
51+
},
52+
serverInterceptors = serverInterceptor {
53+
grpcMetadata = requestHeaders
54+
proceed(it)
55+
},
56+
test = ::unaryCall
57+
)
58+
59+
val authHeaders = grpcMetadata?.getAll("authorization")
60+
assertEquals(1, authHeaders?.size)
61+
assertEquals("Bearer token", authHeaders?.single())
62+
}
63+
64+
@Test
65+
fun `test combine multiple call credentials - should succeed`() {
66+
var grpcMetadata: GrpcMetadata? = null
67+
val callCreds = (NoTLSBearerTokenCredentials("token-1") + NoTLSBearerTokenCredentials("token-2"))
68+
runGrpcTest(
69+
configure = {
70+
credentials = plaintext() + callCreds
71+
},
72+
serverInterceptors = serverInterceptor {
73+
grpcMetadata = requestHeaders
74+
proceed(it)
75+
},
76+
test = ::unaryCall
77+
)
78+
val authHeaders = grpcMetadata?.getAll("authorization")
79+
assertEquals(2, authHeaders?.size)
80+
assertEquals("Bearer token-1", authHeaders?.first())
81+
assertEquals("Bearer token-2", authHeaders?.get(1))
82+
}
83+
84+
@Test
85+
fun `test plaintext call credentials - should fail`() {
86+
assertGrpcFailure(StatusCode.UNAUTHENTICATED, "Transport security required but not present") {
87+
runGrpcTest(
88+
configure = {
89+
credentials = plaintext() + TlsBearerTokenCredentials()
90+
},
91+
test = ::unaryCall
92+
)
93+
}
94+
}
95+
96+
@Test
97+
fun `test tls call credentials - should succeed`() {
98+
val serverTls = TlsServerCredentials(SERVER_CERT_PEM, SERVER_KEY_PEM)
99+
val clientTls = TlsClientCredentials { trustManager(SERVER_CERT_PEM) }
100+
val clientCombined = clientTls + TlsBearerTokenCredentials()
101+
102+
var grpcMetadata: GrpcMetadata? = null
103+
runGrpcTest(
104+
configure = {
105+
credentials = clientCombined
106+
overrideAuthority = "foo.test.google.fr"
107+
},
108+
serverCreds = serverTls,
109+
serverInterceptors = serverInterceptor {
110+
grpcMetadata = requestHeaders
111+
proceed(it)
112+
},
113+
test = ::unaryCall
114+
)
115+
116+
val authHeaders = grpcMetadata?.getAll("authorization")
117+
assertEquals(1, authHeaders?.size)
118+
assertEquals("Bearer token", authHeaders?.single())
119+
}
120+
121+
@Test
122+
fun `test throw status exception - should fail with status`() {
123+
val throwingCallCredentials = object : GrpcCallCredentials {
124+
override suspend fun GrpcMetadata.applyOnMetadata(callOptions: GrpcCallOptions) {
125+
throw StatusException(Status(StatusCode.UNIMPLEMENTED, "This is my custom exception"))
126+
}
127+
128+
override val requiresTransportSecurity: Boolean
129+
get() = false
130+
}
131+
132+
assertGrpcFailure(StatusCode.UNIMPLEMENTED, "This is my custom exception") {
133+
runGrpcTest(
134+
configure = {
135+
credentials = plaintext() + throwingCallCredentials
136+
},
137+
serverInterceptors = serverInterceptor {
138+
proceed(it)
139+
},
140+
test = ::unaryCall
141+
)
142+
}
143+
}
144+
145+
@Test
146+
fun `test interceptor call credentials - should succeed`() {
147+
var grpcMetadata: GrpcMetadata? = null
148+
runGrpcTest(
149+
clientInterceptors = clientInterceptor {
150+
callOptions.callCredentials += NoTLSBearerTokenCredentials()
151+
proceed(it)
152+
},
153+
serverInterceptors = serverInterceptor {
154+
grpcMetadata = requestHeaders
155+
proceed(it)
156+
},
157+
test = ::unaryCall
158+
)
159+
val authHeaders = grpcMetadata?.getAll("authorization")
160+
assertEquals(1, authHeaders?.size)
161+
assertEquals("Bearer token", authHeaders?.single())
162+
}
163+
164+
@Test
165+
fun `test interceptor call credentials without TLS - should fail`() {
166+
assertGrpcFailure(StatusCode.UNAUTHENTICATED, "Transport security required but not present") {
167+
runGrpcTest(
168+
clientInterceptors = clientInterceptor {
169+
callOptions.callCredentials += TlsBearerTokenCredentials()
170+
proceed(it)
171+
},
172+
test = ::unaryCall
173+
)}
174+
}
175+
}
176+
177+
private suspend fun unaryCall(grpcClient: GrpcClient) {
178+
val service = grpcClient.withService<EchoService>()
179+
val response = service.UnaryEcho(EchoRequest { message = "Echo" })
180+
assertEquals("Echo", response.message)
181+
}
182+
183+
class NoTLSBearerTokenCredentials(
184+
val token: String = "token"
185+
): GrpcCallCredentials {
186+
override suspend fun GrpcMetadata.applyOnMetadata(callOptions: GrpcCallOptions) {
187+
// potentially fetching the token from a secure storage
188+
append("Authorization", "Bearer $token")
189+
}
190+
191+
override val requiresTransportSecurity: Boolean
192+
get() = false
193+
}
194+
195+
class TlsBearerTokenCredentials: GrpcCallCredentials {
196+
override suspend fun GrpcMetadata.applyOnMetadata(callOptions: GrpcCallOptions) {
197+
append("Authorization", "Bearer token")
198+
}
199+
}

0 commit comments

Comments
 (0)