Add bearer token authorization for upstreams (#878)

This commit is contained in:
a10zn8
2026-07-03 17:54:11 +03:00
committed by GitHub
parent f2c1bd0628
commit 481c8f1721
18 changed files with 190 additions and 8 deletions

View File

@@ -849,6 +849,18 @@ rpc:
password: "${ETH_PASSWORD}" password: "${ETH_PASSWORD}"
---- ----
| `rpc.bearer-auth` + `rpc.bearer-auth.token`
a| HTTP Bearer token authorization (`Authorization: Bearer <token>` header), if required by the remote server. +
Cannot be used together with `basic-auth`.
Value can also reference env variables, for example:
[source,yaml]
----
rpc:
url: "https://ethereum.com:8545"
bearer-auth:
token: "${ETH_TOKEN}"
----
| `ws.url` | `ws.url`
| WebSocket URL to connect to. | WebSocket URL to connect to.
Optional, but optimizes performance if it's available. Optional, but optimizes performance if it's available.
@@ -859,6 +871,9 @@ Optional, but optimizes performance if it's available.
| `ws.basic-auth` + ... | `ws.basic-auth` + ...
| WebSocket Basic Auth configuration, if required by the remote server | WebSocket Basic Auth configuration, if required by the remote server
| `ws.bearer-auth` + `ws.bearer-auth.token`
| WebSocket Bearer token authorization, if required by the remote server
| `ws.frameSize` | `ws.frameSize`
| WebSocket frame size limit. | WebSocket frame size limit.
Ex `1kb`, `1024` (same as `1kb), `2mb`, etc. Ex `1kb`, `1024` (same as `1kb), `2mb`, etc.

View File

@@ -33,6 +33,10 @@ class AuthConfig {
val password: String, val password: String,
) : ClientAuth() ) : ClientAuth()
class ClientBearerAuth(
val token: String,
) : ClientAuth()
class ClientTlsAuth( class ClientTlsAuth(
var ca: String? = null, var ca: String? = null,
var certificate: String? = null, var certificate: String? = null,

View File

@@ -40,6 +40,18 @@ class AuthConfigReader : YamlConfigReader<AuthConfig>() {
} }
} }
fun readClientBearerAuth(node: MappingNode?): AuthConfig.ClientBearerAuth? {
return getMapping(node, "bearer-auth")?.let { authNode ->
val token = getValueAsString(authNode, "token")
if (token != null) {
AuthConfig.ClientBearerAuth(token)
} else {
log.warn("Bearer auth is not fully configured, token is required")
null
}
}
}
fun readClientTls(node: MappingNode?): AuthConfig.ClientTlsAuth? { fun readClientTls(node: MappingNode?): AuthConfig.ClientTlsAuth? {
return getMapping(node, "tls")?.let { authNode -> return getMapping(node, "tls")?.let { authNode ->
val auth = AuthConfig.ClientTlsAuth() val auth = AuthConfig.ClientTlsAuth()

View File

@@ -136,12 +136,14 @@ data class UpstreamsConfig(
constructor(url: URI) : this(url, DEFAULT_MAX_CONNECTIONS, DEFAULT_QUEUE_SIZE) constructor(url: URI) : this(url, DEFAULT_MAX_CONNECTIONS, DEFAULT_QUEUE_SIZE)
var basicAuth: AuthConfig.ClientBasicAuth? = null var basicAuth: AuthConfig.ClientBasicAuth? = null
var bearerAuth: AuthConfig.ClientBearerAuth? = null
var tls: AuthConfig.ClientTlsAuth? = null var tls: AuthConfig.ClientTlsAuth? = null
} }
data class WsEndpoint(val url: URI) { data class WsEndpoint(val url: URI) {
var origin: URI? = null var origin: URI? = null
var basicAuth: AuthConfig.ClientBasicAuth? = null var basicAuth: AuthConfig.ClientBasicAuth? = null
var bearerAuth: AuthConfig.ClientBearerAuth? = null
var frameSize: Int? = null var frameSize: Int? = null
var msgSize: Int? = null var msgSize: Int? = null
var connections: Int = 1 var connections: Int = 1

View File

@@ -147,6 +147,7 @@ class UpstreamsConfigReader(
getValueAsString(node, "url")?.let { url -> getValueAsString(node, "url")?.let { url ->
val http = UpstreamsConfig.HttpEndpoint(URI(url), DEFAULT_MAX_CONNECTIONS, DEFAULT_QUEUE_SIZE) val http = UpstreamsConfig.HttpEndpoint(URI(url), DEFAULT_MAX_CONNECTIONS, DEFAULT_QUEUE_SIZE)
http.basicAuth = authConfigReader.readClientBasicAuth(node) http.basicAuth = authConfigReader.readClientBasicAuth(node)
http.bearerAuth = readBearerAuth(node, http.basicAuth, url)
http.tls = authConfigReader.readClientTls(node) http.tls = authConfigReader.readClientTls(node)
connection.esplora = http connection.esplora = http
} }
@@ -173,6 +174,19 @@ class UpstreamsConfigReader(
return connection return connection
} }
private fun readBearerAuth(
node: MappingNode?,
basicAuth: AuthConfig.ClientBasicAuth?,
url: String,
): AuthConfig.ClientBearerAuth? {
val bearerAuth = authConfigReader.readClientBearerAuth(node)
if (bearerAuth != null && basicAuth != null) {
log.warn("Both basic-auth and bearer-auth are configured for $url, basic-auth is used")
return null
}
return bearerAuth
}
private fun readRpcConfig(connConfigNode: MappingNode): UpstreamsConfig.HttpEndpoint? { private fun readRpcConfig(connConfigNode: MappingNode): UpstreamsConfig.HttpEndpoint? {
return getMapping(connConfigNode, "rpc")?.let { node -> return getMapping(connConfigNode, "rpc")?.let { node ->
val maxConnections = getValueAsInt(node, "max-connections") ?: DEFAULT_MAX_CONNECTIONS val maxConnections = getValueAsInt(node, "max-connections") ?: DEFAULT_MAX_CONNECTIONS
@@ -181,6 +195,7 @@ class UpstreamsConfigReader(
getValueAsString(node, "url")?.let { url -> getValueAsString(node, "url")?.let { url ->
val http = UpstreamsConfig.HttpEndpoint(URI(url), maxConnections, queueSize) val http = UpstreamsConfig.HttpEndpoint(URI(url), maxConnections, queueSize)
http.basicAuth = authConfigReader.readClientBasicAuth(node) http.basicAuth = authConfigReader.readClientBasicAuth(node)
http.bearerAuth = readBearerAuth(node, http.basicAuth, url)
http.tls = authConfigReader.readClientTls(node) http.tls = authConfigReader.readClientTls(node)
http http
} }
@@ -224,6 +239,7 @@ class UpstreamsConfigReader(
ws.origin = URI(origin) ws.origin = URI(origin)
} }
ws.basicAuth = authConfigReader.readClientBasicAuth(node) ws.basicAuth = authConfigReader.readClientBasicAuth(node)
ws.bearerAuth = readBearerAuth(node, ws.basicAuth, url)
getValueAsBytes(node, "frameSize")?.let { getValueAsBytes(node, "frameSize")?.let {
if (it < 65_535) { if (it < 65_535) {

View File

@@ -52,7 +52,7 @@ class BitcoinUpstreamCreator(
fileResolver.resolve(ca).readBytes() fileResolver.resolve(ca).readBytes()
} }
} }
EsploraClient(endpoint.url, endpoint.basicAuth, tls) EsploraClient(endpoint.url, endpoint.basicAuth, tls, endpoint.bearerAuth)
} }
val extractBlock = ExtractBlock() val extractBlock = ExtractBlock()

View File

@@ -84,6 +84,7 @@ open class GenericConnectorFactoryCreator(
monitoringCfg.nettyMetricsConfig.enabled, monitoringCfg.nettyMetricsConfig.enabled,
httpScheduler, httpScheduler,
customHeaders, customHeaders,
conn.bearerAuth,
) )
} }
} }
@@ -106,6 +107,7 @@ open class GenericConnectorFactoryCreator(
).apply { ).apply {
config = endpoint config = endpoint
basicAuth = endpoint.basicAuth basicAuth = endpoint.basicAuth
bearerAuth = endpoint.bearerAuth
this.customHeaders = customHeaders this.customHeaders = customHeaders
} }
val wsApi = WsConnectionPoolFactory( val wsApi = WsConnectionPoolFactory(

View File

@@ -21,6 +21,7 @@ class BasicHttpFactory(
private val nettyMetricsEnabled: Boolean, private val nettyMetricsEnabled: Boolean,
private val httpScheduler: Scheduler, private val httpScheduler: Scheduler,
private val customHeaders: Map<String, String> = emptyMap(), private val customHeaders: Map<String, String> = emptyMap(),
private val bearerAuth: AuthConfig.ClientBearerAuth? = null,
) : HttpFactory { ) : HttpFactory {
private val log = LoggerFactory.getLogger(this::class.java) private val log = LoggerFactory.getLogger(this::class.java)
@@ -50,8 +51,8 @@ class BasicHttpFactory(
) )
if (chain.type.apiType == ApiType.REST) { if (chain.type.apiType == ApiType.REST) {
return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, chain, basicAuth, tls, customHeaders) return RestHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, chain, basicAuth, tls, customHeaders, bearerAuth)
} }
return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls, customHeaders) return JsonRpcHttpReader(url, maxConnections, queueSize, metrics, httpScheduler, basicAuth, tls, customHeaders, bearerAuth)
} }
} }

View File

@@ -28,6 +28,7 @@ abstract class HttpReader(
basicAuth: AuthConfig.ClientBasicAuth? = null, basicAuth: AuthConfig.ClientBasicAuth? = null,
tlsCAAuth: ByteArray? = null, tlsCAAuth: ByteArray? = null,
customHeaders: Map<String, String> = emptyMap(), customHeaders: Map<String, String> = emptyMap(),
bearerAuth: AuthConfig.ClientBearerAuth? = null,
) : ChainReader { ) : ChainReader {
constructor() : this("", 1500, 1000, null) constructor() : this("", 1500, 1000, null)
@@ -66,6 +67,13 @@ abstract class HttpReader(
build = build.headers(headers) build = build.headers(headers)
} }
if (basicAuth == null) {
bearerAuth?.let { auth ->
val headers = Consumer { h: HttpHeaders -> h.add(HttpHeaderNames.AUTHORIZATION, "Bearer ${auth.token}") }
build = build.headers(headers)
}
}
if (customHeaders.isNotEmpty()) { if (customHeaders.isNotEmpty()) {
val headers = Consumer { h: HttpHeaders -> val headers = Consumer { h: HttpHeaders ->
customHeaders.forEach { (key, value) -> customHeaders.forEach { (key, value) ->

View File

@@ -34,10 +34,11 @@ import java.security.cert.X509Certificate
import java.util.Base64 import java.util.Base64
import java.util.function.Consumer import java.util.function.Consumer
class EsploraClient( class EsploraClient @JvmOverloads constructor(
private val url: URI, private val url: URI,
basicAuth: AuthConfig.ClientBasicAuth? = null, basicAuth: AuthConfig.ClientBasicAuth? = null,
tlsCAAuth: ByteArray? = null, tlsCAAuth: ByteArray? = null,
bearerAuth: AuthConfig.ClientBearerAuth? = null,
) { ) {
companion object { companion object {
@@ -62,6 +63,13 @@ class EsploraClient(
build = build.headers(headers) build = build.headers(headers)
} }
if (basicAuth == null) {
bearerAuth?.let { auth ->
val headers = Consumer { h: HttpHeaders -> h.add(HttpHeaderNames.AUTHORIZATION, "Bearer ${auth.token}") }
build = build.headers(headers)
}
}
tlsCAAuth?.let { auth -> tlsCAAuth?.let { auth ->
val cf = CertificateFactory.getInstance("X.509") val cf = CertificateFactory.getInstance("X.509")
val cert = cf.generateCertificate(ByteArrayInputStream(auth)) as X509Certificate val cert = cf.generateCertificate(ByteArrayInputStream(auth)) as X509Certificate

View File

@@ -21,6 +21,7 @@ open class WsConnectionFactory(
) { ) {
var basicAuth: AuthConfig.ClientBasicAuth? = null var basicAuth: AuthConfig.ClientBasicAuth? = null
var bearerAuth: AuthConfig.ClientBearerAuth? = null
var config: UpstreamsConfig.WsEndpoint? = null var config: UpstreamsConfig.WsEndpoint? = null
var customHeaders: Map<String, String> = emptyMap() var customHeaders: Map<String, String> = emptyMap()
@@ -47,7 +48,7 @@ open class WsConnectionFactory(
} }
open fun createWsConnection(connIndex: Int = 0): WsConnection = open fun createWsConnection(connIndex: Int = 0): WsConnection =
WsConnectionImpl(uri, origin, basicAuth, metrics(connIndex), scheduler, eventsScheduler, customHeaders).also { ws -> WsConnectionImpl(uri, origin, basicAuth, metrics(connIndex), scheduler, eventsScheduler, customHeaders, bearerAuth).also { ws ->
config?.frameSize?.let { config?.frameSize?.let {
ws.frameSize = it ws.frameSize = it
} }

View File

@@ -67,6 +67,7 @@ open class WsConnectionImpl(
private val scheduler: Scheduler, private val scheduler: Scheduler,
private val eventsScheduler: Scheduler, private val eventsScheduler: Scheduler,
private val customHeaders: Map<String, String> = emptyMap(), private val customHeaders: Map<String, String> = emptyMap(),
private val bearerAuth: AuthConfig.ClientBearerAuth? = null,
) : AutoCloseable, WsConnection, Cloneable { ) : AutoCloseable, WsConnection, Cloneable {
companion object { companion object {
@@ -227,6 +228,11 @@ open class WsConnectionImpl(
val base64password = Base64.getEncoder().encodeToString(tmp.toByteArray()) val base64password = Base64.getEncoder().encodeToString(tmp.toByteArray())
headers.add(HttpHeaderNames.AUTHORIZATION, "Basic $base64password") headers.add(HttpHeaderNames.AUTHORIZATION, "Basic $base64password")
} }
if (basicAuth == null) {
bearerAuth?.let { auth ->
headers.add(HttpHeaderNames.AUTHORIZATION, "Bearer ${auth.token}")
}
}
customHeaders.forEach { (key, value) -> customHeaders.forEach { (key, value) ->
headers.add(key, value) headers.add(key, value)
} }

View File

@@ -36,7 +36,8 @@ class RestHttpReader(
basicAuth: AuthConfig.ClientBasicAuth? = null, basicAuth: AuthConfig.ClientBasicAuth? = null,
tlsCAAuth: ByteArray? = null, tlsCAAuth: ByteArray? = null,
customHeaders: Map<String, String> = emptyMap(), customHeaders: Map<String, String> = emptyMap(),
) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders) { bearerAuth: AuthConfig.ClientBearerAuth? = null,
) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders, bearerAuth) {
private val parser = ResponseRpcParser() private val parser = ResponseRpcParser()
private val requestParser = RestRequestParser private val requestParser = RestRequestParser

View File

@@ -36,7 +36,7 @@ import java.util.function.Function
/** /**
* JSON RPC client * JSON RPC client
*/ */
class JsonRpcHttpReader( class JsonRpcHttpReader @JvmOverloads constructor(
target: String, target: String,
maxConnections: Int, maxConnections: Int,
queueSize: Int, queueSize: Int,
@@ -45,7 +45,8 @@ class JsonRpcHttpReader(
basicAuth: AuthConfig.ClientBasicAuth? = null, basicAuth: AuthConfig.ClientBasicAuth? = null,
tlsCAAuth: ByteArray? = null, tlsCAAuth: ByteArray? = null,
customHeaders: Map<String, String> = emptyMap(), customHeaders: Map<String, String> = emptyMap(),
) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders) { bearerAuth: AuthConfig.ClientBearerAuth? = null,
) : HttpReader(target, maxConnections, queueSize, metrics, basicAuth, tlsCAAuth, customHeaders, bearerAuth) {
private val parser = ResponseRpcParser() private val parser = ResponseRpcParser()
private val streamParser = JsonRpcStreamParser() private val streamParser = JsonRpcStreamParser()

View File

@@ -35,6 +35,29 @@ class AuthConfigReaderSpec extends Specification {
act.password == "258fe4149c199ad8f2811a68f20154fc" act.password == "258fe4149c199ad8f2811a68f20154fc"
} }
def "Read bearer-auth for client"() {
setup:
def yaml =
"bearer-auth:\n" +
" token: 9c199ad8f281f20154fc258fe41a6814"
when:
def act = reader.readClientBearerAuth(reader.readNode(yaml))
then:
act != null
act.token == "9c199ad8f281f20154fc258fe41a6814"
}
def "Read bearer-auth without token as null"() {
setup:
def yaml =
"bearer-auth:\n" +
" something: else"
when:
def act = reader.readClientBearerAuth(reader.readNode(yaml))
then:
act == null
}
def "Read tls for client"() { def "Read tls for client"() {
setup: setup:
def yaml = def yaml =

View File

@@ -75,6 +75,38 @@ class UpstreamsConfigReaderSpec extends Specification {
} }
} }
def "Parse config with bearer auth"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("configs/upstreams-bearer-auth.yaml")
when:
def act = reader.readInternal(config)
then:
act != null
act.upstreams.size() == 2
with(act.upstreams.get(0)) {
id == "local"
connection instanceof UpstreamsConfig.RpcConnection
with((UpstreamsConfig.RpcConnection) connection) {
rpc.basicAuth == null
rpc.bearerAuth != null
rpc.bearerAuth.token == "9c199ad8f281f20154fc258fe41a6814"
ws != null
ws.basicAuth == null
ws.bearerAuth != null
ws.bearerAuth.token == "258fe4149c199ad8f2811a68f20154fc"
}
}
with(act.upstreams.get(1)) {
id == "both-auth"
connection instanceof UpstreamsConfig.RpcConnection
with((UpstreamsConfig.RpcConnection) connection) {
// when both are configured basic-auth wins and bearer-auth is dropped
rpc.basicAuth != null
rpc.bearerAuth == null
}
}
}
def "Parse websocket-only config"() { def "Parse websocket-only config"() {
setup: setup:
def config = this.class.getClassLoader().getResourceAsStream("configs/upstreams-ws-only.yaml") def config = this.class.getClassLoader().getResourceAsStream("configs/upstreams-ws-only.yaml")

View File

@@ -16,6 +16,7 @@
package io.emeraldpay.dshackle.upstream.rpcclient package io.emeraldpay.dshackle.upstream.rpcclient
import io.emeraldpay.dshackle.config.AuthConfig
import io.emeraldpay.dshackle.test.TestingCommons import io.emeraldpay.dshackle.test.TestingCommons
import io.emeraldpay.dshackle.upstream.ChainException import io.emeraldpay.dshackle.upstream.ChainException
import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainRequest
@@ -69,6 +70,28 @@ class JsonRpcHttpReaderSpec extends Specification {
new String(act.result) == '"0x98de45"' new String(act.result) == '"0x98de45"'
} }
def "Make a request with bearer auth"() {
setup:
def bearerAuth = new AuthConfig.ClientBearerAuth("test-token-123")
JsonRpcHttpReader client = new JsonRpcHttpReader("localhost:${mockServer.port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:], bearerAuth)
def resp = '{' +
' "jsonrpc": "2.0",' +
' "result": "0x98de45",' +
' "error": null,' +
' "id": 15' +
'}'
mockServer.when(
HttpRequest.request().withHeader("authorization", "Bearer test-token-123")
).respond(
HttpResponse.response(resp)
)
when:
def act = client.read(new ChainRequest("test", new ListParams())).block(Duration.ofSeconds(5))
then:
act.error == null
new String(act.result) == '"0x98de45"'
}
def "Produces RPC Exception on error status code"() { def "Produces RPC Exception on error status code"() {
setup: setup:
def client = new JsonRpcHttpReader("localhost:${mockServer.port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:]) def client = new JsonRpcHttpReader("localhost:${mockServer.port}", 50, 50, metrics, Schedulers.boundedElastic(), null, null, [:])

View File

@@ -0,0 +1,27 @@
version: v1
upstreams:
- id: local
chain: ethereum
connection:
ethereum:
rpc:
url: "http://localhost:8545"
bearer-auth:
token: 9c199ad8f281f20154fc258fe41a6814
ws:
url: "ws://localhost:8546"
origin: "http://localhost"
bearer-auth:
token: 258fe4149c199ad8f2811a68f20154fc
- id: both-auth
chain: ethereum
connection:
ethereum:
rpc:
url: "http://localhost:8545"
basic-auth:
username: 4fc258fe41a68149c199ad8f281f2015
password: 1a68f20154fc258fe4149c199ad8f281
bearer-auth:
token: 9c199ad8f281f20154fc258fe41a6814