Skip to content

Commit bda9297

Browse files
authored
grpc: Call credentials (#530)
1 parent 16d12f9 commit bda9297

File tree

26 files changed

+1235
-139
lines changed

26 files changed

+1235
-139
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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+
import kotlinx.rpc.grpc.descriptor.MethodDescriptor
9+
import kotlinx.rpc.grpc.plus
10+
11+
/**
12+
* Provides per-call authentication credentials for gRPC calls.
13+
*
14+
* Call credentials are used to attach authentication information (such as tokens, API keys, or signatures)
15+
* to individual gRPC calls through metadata headers. Unlike client credentials, which establish
16+
* the transport security layer (e.g., TLS), call credentials operate at the application layer
17+
* and can be dynamically generated for each request.
18+
*
19+
* ## Usage
20+
*
21+
* Implement this interface to create custom authentication mechanisms:
22+
*
23+
* ```kotlin
24+
* class BearerTokenCredentials(private val token: String) : GrpcCallCredentials {
25+
* override suspend fun Context.getRequestMetadata(): GrpcMetadata {
26+
* return buildGrpcMetadata {
27+
* append("Authorization", "Bearer $token")
28+
* }
29+
* }
30+
* }
31+
* ```
32+
*
33+
* ## Context-Aware Credentials
34+
*
35+
* Use the [Context] to implement sophisticated authentication strategies:
36+
*
37+
* ```kotlin
38+
* class MethodScopedCredentials : GrpcCallCredentials {
39+
* override suspend fun Context.getRequestMetadata(): GrpcMetadata {
40+
* val scope = when (methodName) {
41+
* "GetUser" -> "read:users"
42+
* "UpdateUser" -> "write:users"
43+
* else -> "default"
44+
* }
45+
* val token = fetchTokenWithScope(scope)
46+
* return buildGrpcMetadata {
47+
* append("Authorization", "Bearer $token")
48+
* }
49+
* }
50+
* }
51+
* ```
52+
*
53+
* ## Combining Credentials
54+
*
55+
* Credentials can be combined using the [plus] operator or [combine] function:
56+
*
57+
* ```kotlin
58+
* val credentials = TlsClientCredentials(...) + BearerTokenCredentials("my-token")
59+
* ```
60+
*
61+
* ## Transport Security
62+
*
63+
* By default, call credentials require transport security (TLS) to prevent credential leakage.
64+
* Override [requiresTransportSecurity] to `false` only for testing or non-production environments.
65+
*
66+
* @see getRequestMetadata
67+
* @see Context
68+
* @see requiresTransportSecurity
69+
* @see plus
70+
* @see combine
71+
*/
72+
public interface GrpcCallCredentials {
73+
74+
/**
75+
* Retrieves authentication metadata for the gRPC call.
76+
*
77+
* This method is invoked before each gRPC call to generate authentication headers or metadata.
78+
* Implementations should return a [GrpcMetadata] object containing the necessary authentication
79+
* information for the request.
80+
*
81+
* The method is suspending to allow asynchronous operations such as token retrieval from secure storage.
82+
*
83+
* ## Context Information
84+
*
85+
* The [Context] receiver provides access to call-specific information:
86+
* - [Context.methodName]: The method being invoked (for method-specific auth)
87+
* - [Context.authority]: The target authority (for tenant-aware auth)
88+
*
89+
* ## Examples
90+
*
91+
* Simple bearer token:
92+
* ```kotlin
93+
* override suspend fun Context.getRequestMetadata(): GrpcMetadata {
94+
* return buildGrpcMetadata {
95+
* append("Authorization", "Bearer $token")
96+
* }
97+
* }
98+
* ```
99+
*
100+
* Throwing a [kotlinx.rpc.grpc.StatusException] to fail the call:
101+
* ```kotlin
102+
* override suspend fun Context.getRequestMetadata(): GrpcMetadata {
103+
* val token = try {
104+
* refreshToken()
105+
* } catch (e: Exception) {
106+
* throw StatusException(Status(StatusCode.UNAUTHENTICATED, "Token refresh failed"))
107+
* }
108+
*
109+
* return buildGrpcMetadata {
110+
* append("Authorization", "Bearer $token")
111+
* }
112+
* }
113+
* ```
114+
*
115+
* @receiver Context information about the call being authenticated.
116+
* @return Metadata containing authentication information to attach to the request.
117+
* @throws kotlinx.rpc.grpc.StatusException to abort the call with a specific gRPC status.
118+
*/
119+
public suspend fun Context.getRequestMetadata(): GrpcMetadata
120+
121+
/**
122+
* Indicates whether this credential requires transport security (TLS).
123+
*
124+
* When `true` (the default), the credential will only be applied to calls over secure transports.
125+
* If transport security is not present, the call will fail with [kotlinx.rpc.grpc.StatusCode.UNAUTHENTICATED].
126+
*
127+
* Set to `false` only for credentials that are safe to send over insecure connections,
128+
* such as in testing environments or for non-sensitive authentication mechanisms.
129+
*
130+
* @return `true` if transport security is required, `false` otherwise.
131+
*/
132+
public val requiresTransportSecurity: Boolean
133+
get() = true
134+
135+
/**
136+
* Context information available when retrieving call credentials.
137+
*
138+
* Provides metadata about the RPC call to enable method-specific authentication strategies.
139+
*
140+
* @property methodName The method name of the RPC being invoked.
141+
* @property authority The authority (host:port) for this call.
142+
*/
143+
// TODO: check whether we should add GrpcCallOptions in the context (KRPC-232)
144+
public data class Context(
145+
val authority: String,
146+
val methodName: String,
147+
)
148+
}
149+
150+
/**
151+
* Combines two call credentials into a single credential that applies both.
152+
*
153+
* The resulting credential will apply both sets of credentials in order, allowing
154+
* multiple authentication mechanisms to be used simultaneously. For example,
155+
* combining channel credentials with call credentials, or applying multiple
156+
* authentication headers to the same call.
157+
*
158+
* The combined credential requires transport security if either of the original
159+
* credentials requires it.
160+
*
161+
* ## Example
162+
*
163+
* ```kotlin
164+
* val tlsCreds = TlsClientCredentials { ... }
165+
* val bearerToken = BearerTokenCredentials("my-token")
166+
* val combined = tlsCreds + bearerToken
167+
* ```
168+
*
169+
* Multiple credentials can be chained:
170+
* ```kotlin
171+
* val combined = creds1 + creds2 + creds3
172+
* ```
173+
*
174+
* @param other The credential to combine with this one.
175+
* @return A new credential that applies both credentials.
176+
* @see combine
177+
*/
178+
public operator fun GrpcCallCredentials.plus(other: GrpcCallCredentials): GrpcCallCredentials {
179+
return CombinedCallCredentials(this, other)
180+
}
181+
182+
/**
183+
* Combines two call credentials into a single credential that applies both.
184+
*
185+
* This is an alias for the [plus] operator, providing a more explicit method name
186+
* for combining credentials.
187+
*
188+
* @param other The credential to combine with this one.
189+
* @return A new credential that applies both credentials.
190+
* @see plus
191+
*/
192+
public fun GrpcCallCredentials.combine(other: GrpcCallCredentials): GrpcCallCredentials = this + other
193+
194+
/**
195+
* A call credential that performs no authentication.
196+
*
197+
* This is useful as a no-op placeholder or for disabling authentication in specific scenarios.
198+
* Since it performs no authentication, it does not require transport security.
199+
*
200+
* ## Example
201+
*
202+
* ```kotlin
203+
* val credentials = if (useAuth) {
204+
* BearerTokenCredentials(token)
205+
* } else {
206+
* EmptyCallCredentials
207+
* }
208+
* ```
209+
*/
210+
public object EmptyCallCredentials : GrpcCallCredentials {
211+
override suspend fun GrpcCallCredentials.Context.getRequestMetadata(): GrpcMetadata {
212+
return GrpcMetadata()
213+
}
214+
override val requiresTransportSecurity: Boolean = false
215+
}
216+
217+
internal class CombinedCallCredentials(
218+
private val first: GrpcCallCredentials,
219+
private val second: GrpcCallCredentials
220+
) : GrpcCallCredentials {
221+
override suspend fun GrpcCallCredentials.Context.getRequestMetadata(): GrpcMetadata {
222+
val firstMetadata = with(first) { getRequestMetadata() }
223+
val secondMetadata = with(second) { getRequestMetadata() }
224+
return firstMetadata + secondMetadata
225+
}
226+
227+
override val requiresTransportSecurity: Boolean = first.requiresTransportSecurity || second.requiresTransportSecurity
228+
}

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,49 @@ public class GrpcCallOptions {
4949
* @see GrpcCompression
5050
*/
5151
public var compression: GrpcCompression = GrpcCompression.None
52+
53+
/**
54+
* Per-call authentication credentials to apply to this RPC.
55+
*
56+
* Call credentials allow dynamic, per-request authentication by adding metadata headers
57+
* such as bearer tokens, API keys, or custom authentication information.
58+
*
59+
* ## Default Behavior
60+
* Defaults to [EmptyCallCredentials], which attaches no authentication headers.
61+
*
62+
* ## Usage Patterns
63+
*
64+
* ### Setting in Client Interceptors
65+
* Call credentials can be dynamically added or modified in client interceptors:
66+
*
67+
* ```kotlin
68+
* val client = GrpcClient("example.com", 443) {
69+
* interceptors {
70+
* clientInterceptor {
71+
* callOptions.callCredentials += BearerTokenCredentials(getToken())
72+
* proceed(it)
73+
* }
74+
* }
75+
* }
76+
* ```
77+
*
78+
* ### Combining Multiple Credentials
79+
* Multiple call credentials can be combined using the `+` operator:
80+
*
81+
* ```kotlin
82+
* callOptions.callCredentials = bearerToken + apiKey
83+
* // or incrementally:
84+
* callOptions.callCredentials += additionalCredential
85+
* ```
86+
*
87+
* ### Transport Security
88+
* If any call credential requires transport security ([GrpcCallCredentials.requiresTransportSecurity]),
89+
* the call will fail with [kotlinx.rpc.grpc.StatusCode.UNAUTHENTICATED] unless the channel
90+
* is configured with TLS credentials (which is the default, except if the client uses `plaintext()`).
91+
*
92+
* @see GrpcCallCredentials
93+
* @see EmptyCallCredentials
94+
* @see GrpcCallCredentials.plus
95+
*/
96+
public var callCredentials: GrpcCallCredentials = EmptyCallCredentials
5297
}

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ private typealias RequestClient = Any
3333

3434
/**
3535
* GrpcClient manages gRPC communication by providing implementation for making asynchronous RPC calls.
36-
*
37-
* @field channel The [kotlinx.rpc.grpc.client.internal.ManagedChannel] used to communicate with remote gRPC services.
3836
*/
3937
public class GrpcClient internal constructor(
4038
internal val channel: ManagedChannel,
4139
messageCodecResolver: MessageCodecResolver = EmptyMessageCodecResolver,
4240
internal val interceptors: List<ClientInterceptor>,
41+
// the default call credentials that are automatically attached to all calls made with this client
42+
internal val callCredentials: GrpcCallCredentials,
4343
) : RpcClient {
4444
private val delegates = RpcInternalConcurrentHashMap<String, GrpcServiceDelegate>()
4545
private val messageCodecResolver = messageCodecResolver + ThrowingMessageCodecResolver
@@ -182,7 +182,8 @@ private fun GrpcClient(
182182
config: GrpcClientConfiguration,
183183
): GrpcClient {
184184
val channel = builder.applyConfig(config).buildChannel()
185-
return GrpcClient(channel, config.messageCodecResolver, config.interceptors)
185+
val callCredentials = config.credentials?.realCallCredentials ?: EmptyCallCredentials
186+
return GrpcClient(channel, config.messageCodecResolver, config.interceptors, callCredentials)
186187
}
187188

188189

@@ -234,7 +235,7 @@ public class GrpcClientConfiguration internal constructor() {
234235
* @see tls
235236
* @see plaintext
236237
*/
237-
public var credentials: ClientCredentials? = null
238+
public var credentials: GrpcClientCredentials? = null
238239

239240
/**
240241
* Overrides the authority used with TLS and HTTP virtual hosting.
@@ -272,7 +273,7 @@ public class GrpcClientConfiguration internal constructor() {
272273
*
273274
* @return An insecure [ClientCredentials] instance that must be passed to [credentials].
274275
*/
275-
public fun plaintext(): ClientCredentials = createInsecureClientCredentials()
276+
public fun plaintext(): GrpcClientCredentials = GrpcInsecureClientCredentials()
276277

277278
/**
278279
* Configures and creates secure client credentials for the gRPC client.
@@ -286,13 +287,13 @@ public class GrpcClientConfiguration internal constructor() {
286287
* Alternatively, you can use the [TlsClientCredentials] constructor.
287288
*
288289
* @param configure A configuration block that allows setting up the TLS parameters
289-
* using the [TlsClientCredentialsBuilder].
290+
* using the [GrpcTlsClientCredentialsBuilder].
290291
* @return A secure [ClientCredentials] instance that must be passed to [credentials].
291292
*
292293
* @see credentials
293294
*/
294-
public fun tls(configure: TlsClientCredentialsBuilder.() -> Unit): ClientCredentials =
295-
TlsClientCredentials(configure)
295+
public fun tls(configure: GrpcTlsClientCredentialsBuilder.() -> Unit): GrpcClientCredentials =
296+
GrpcTlsClientCredentials(configure)
296297

297298
/**
298299
* Configures keep-alive settings for the gRPC client.

0 commit comments

Comments
 (0)