mirror of https://github.com/ethereum/go-ethereum
node, rpc: add JWT auth support in client (#24911)
This adds a generic mechanism for 'dial options' in the RPC client, and also implements a specific dial option for the JWT authentication mechanism used by the engine API. Some real tests for the server-side authentication handling are also added. Co-authored-by: Joshua Gutow <jgutow@optimism.io> Co-authored-by: Felix Lange <fjl@twurst.com>pull/25669/head
parent
7f2890a9be
commit
90711efb0a
@ -0,0 +1,45 @@ |
|||||||
|
// Copyright 2022 The go-ethereum Authors
|
||||||
|
// This file is part of the go-ethereum library.
|
||||||
|
//
|
||||||
|
// The go-ethereum library is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// The go-ethereum library is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Lesser General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Lesser General Public License
|
||||||
|
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package node |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/rpc" |
||||||
|
"github.com/golang-jwt/jwt/v4" |
||||||
|
) |
||||||
|
|
||||||
|
// NewJWTAuth creates an rpc client authentication provider that uses JWT. The
|
||||||
|
// secret MUST be 32 bytes (256 bits) as defined by the Engine-API authentication spec.
|
||||||
|
//
|
||||||
|
// See https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md
|
||||||
|
// for more details about this authentication scheme.
|
||||||
|
func NewJWTAuth(jwtsecret [32]byte) rpc.HTTPAuth { |
||||||
|
return func(h http.Header) error { |
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ |
||||||
|
"iat": &jwt.NumericDate{Time: time.Now()}, |
||||||
|
}) |
||||||
|
s, err := token.SignedString(jwtsecret[:]) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to create JWT token: %w", err) |
||||||
|
} |
||||||
|
h.Set("Authorization", "Bearer "+s) |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,237 @@ |
|||||||
|
// Copyright 2022 The go-ethereum Authors
|
||||||
|
// This file is part of the go-ethereum library.
|
||||||
|
//
|
||||||
|
// The go-ethereum library is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// The go-ethereum library is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Lesser General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Lesser General Public License
|
||||||
|
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package node |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
crand "crypto/rand" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"os" |
||||||
|
"path" |
||||||
|
"testing" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/common/hexutil" |
||||||
|
"github.com/ethereum/go-ethereum/rpc" |
||||||
|
"github.com/golang-jwt/jwt/v4" |
||||||
|
) |
||||||
|
|
||||||
|
type helloRPC string |
||||||
|
|
||||||
|
func (ta helloRPC) HelloWorld() (string, error) { |
||||||
|
return string(ta), nil |
||||||
|
} |
||||||
|
|
||||||
|
type authTest struct { |
||||||
|
name string |
||||||
|
endpoint string |
||||||
|
prov rpc.HTTPAuth |
||||||
|
expectDialFail bool |
||||||
|
expectCall1Fail bool |
||||||
|
expectCall2Fail bool |
||||||
|
} |
||||||
|
|
||||||
|
func (at *authTest) Run(t *testing.T) { |
||||||
|
ctx := context.Background() |
||||||
|
cl, err := rpc.DialOptions(ctx, at.endpoint, rpc.WithHTTPAuth(at.prov)) |
||||||
|
if at.expectDialFail { |
||||||
|
if err == nil { |
||||||
|
t.Fatal("expected initial dial to fail") |
||||||
|
} else { |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("failed to dial rpc endpoint: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
var x string |
||||||
|
err = cl.CallContext(ctx, &x, "engine_helloWorld") |
||||||
|
if at.expectCall1Fail { |
||||||
|
if err == nil { |
||||||
|
t.Fatal("expected call 1 to fail") |
||||||
|
} else { |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("failed to call rpc endpoint: %v", err) |
||||||
|
} |
||||||
|
if x != "hello engine" { |
||||||
|
t.Fatalf("method was silent but did not return expected value: %q", x) |
||||||
|
} |
||||||
|
|
||||||
|
err = cl.CallContext(ctx, &x, "eth_helloWorld") |
||||||
|
if at.expectCall2Fail { |
||||||
|
if err == nil { |
||||||
|
t.Fatal("expected call 2 to fail") |
||||||
|
} else { |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("failed to call rpc endpoint: %v", err) |
||||||
|
} |
||||||
|
if x != "hello eth" { |
||||||
|
t.Fatalf("method was silent but did not return expected value: %q", x) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestAuthEndpoints(t *testing.T) { |
||||||
|
var secret [32]byte |
||||||
|
if _, err := crand.Read(secret[:]); err != nil { |
||||||
|
t.Fatalf("failed to create jwt secret: %v", err) |
||||||
|
} |
||||||
|
// Geth must read it from a file, and does not support in-memory JWT secrets, so we create a temporary file.
|
||||||
|
jwtPath := path.Join(t.TempDir(), "jwt_secret") |
||||||
|
if err := os.WriteFile(jwtPath, []byte(hexutil.Encode(secret[:])), 0600); err != nil { |
||||||
|
t.Fatalf("failed to prepare jwt secret file: %v", err) |
||||||
|
} |
||||||
|
// We get ports assigned by the node automatically
|
||||||
|
conf := &Config{ |
||||||
|
HTTPHost: "127.0.0.1", |
||||||
|
HTTPPort: 0, |
||||||
|
WSHost: "127.0.0.1", |
||||||
|
WSPort: 0, |
||||||
|
AuthAddr: "127.0.0.1", |
||||||
|
AuthPort: 0, |
||||||
|
JWTSecret: jwtPath, |
||||||
|
|
||||||
|
WSModules: []string{"eth", "engine"}, |
||||||
|
HTTPModules: []string{"eth", "engine"}, |
||||||
|
} |
||||||
|
node, err := New(conf) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("could not create a new node: %v", err) |
||||||
|
} |
||||||
|
// register dummy apis so we can test the modules are available and reachable with authentication
|
||||||
|
node.RegisterAPIs([]rpc.API{ |
||||||
|
{ |
||||||
|
Namespace: "engine", |
||||||
|
Version: "1.0", |
||||||
|
Service: helloRPC("hello engine"), |
||||||
|
Public: true, |
||||||
|
Authenticated: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
Namespace: "eth", |
||||||
|
Version: "1.0", |
||||||
|
Service: helloRPC("hello eth"), |
||||||
|
Public: true, |
||||||
|
Authenticated: true, |
||||||
|
}, |
||||||
|
}) |
||||||
|
if err := node.Start(); err != nil { |
||||||
|
t.Fatalf("failed to start test node: %v", err) |
||||||
|
} |
||||||
|
defer node.Close() |
||||||
|
|
||||||
|
// sanity check we are running different endpoints
|
||||||
|
if a, b := node.WSEndpoint(), node.WSAuthEndpoint(); a == b { |
||||||
|
t.Fatalf("expected ws and auth-ws endpoints to be different, got: %q and %q", a, b) |
||||||
|
} |
||||||
|
if a, b := node.HTTPEndpoint(), node.HTTPAuthEndpoint(); a == b { |
||||||
|
t.Fatalf("expected http and auth-http endpoints to be different, got: %q and %q", a, b) |
||||||
|
} |
||||||
|
|
||||||
|
goodAuth := NewJWTAuth(secret) |
||||||
|
var otherSecret [32]byte |
||||||
|
if _, err := crand.Read(otherSecret[:]); err != nil { |
||||||
|
t.Fatalf("failed to create jwt secret: %v", err) |
||||||
|
} |
||||||
|
badAuth := NewJWTAuth(otherSecret) |
||||||
|
|
||||||
|
notTooLong := time.Second * 57 |
||||||
|
tooLong := time.Second * 60 |
||||||
|
requestDelay := time.Second |
||||||
|
|
||||||
|
testCases := []authTest{ |
||||||
|
// Auth works
|
||||||
|
{name: "ws good", endpoint: node.WSAuthEndpoint(), prov: goodAuth, expectCall1Fail: false}, |
||||||
|
{name: "http good", endpoint: node.HTTPAuthEndpoint(), prov: goodAuth, expectCall1Fail: false}, |
||||||
|
|
||||||
|
// Try a bad auth
|
||||||
|
{name: "ws bad", endpoint: node.WSAuthEndpoint(), prov: badAuth, expectDialFail: true}, // ws auth is immediate
|
||||||
|
{name: "http bad", endpoint: node.HTTPAuthEndpoint(), prov: badAuth, expectCall1Fail: true}, // http auth is on first call
|
||||||
|
|
||||||
|
// A common mistake with JWT is to allow the "none" algorithm, which is a valid JWT but not secure.
|
||||||
|
{name: "ws none", endpoint: node.WSAuthEndpoint(), prov: noneAuth(secret), expectDialFail: true}, |
||||||
|
{name: "http none", endpoint: node.HTTPAuthEndpoint(), prov: noneAuth(secret), expectCall1Fail: true}, |
||||||
|
|
||||||
|
// claims of 5 seconds or more, older or newer, are not allowed
|
||||||
|
{name: "ws too old", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, -tooLong), expectDialFail: true}, |
||||||
|
{name: "http too old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, -tooLong), expectCall1Fail: true}, |
||||||
|
// note: for it to be too long we need to add a delay, so that once we receive the request, the difference has not dipped below the "tooLong"
|
||||||
|
{name: "ws too new", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, tooLong+requestDelay), expectDialFail: true}, |
||||||
|
{name: "http too new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, tooLong+requestDelay), expectCall1Fail: true}, |
||||||
|
|
||||||
|
// Try offset the time, but stay just within bounds
|
||||||
|
{name: "ws old", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, -notTooLong)}, |
||||||
|
{name: "http old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, -notTooLong)}, |
||||||
|
{name: "ws new", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, notTooLong)}, |
||||||
|
{name: "http new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, notTooLong)}, |
||||||
|
|
||||||
|
// ws only authenticates on initial dial, then continues communication
|
||||||
|
{name: "ws single auth", endpoint: node.WSAuthEndpoint(), prov: changingAuth(goodAuth, badAuth)}, |
||||||
|
{name: "http call fail auth", endpoint: node.HTTPAuthEndpoint(), prov: changingAuth(goodAuth, badAuth), expectCall2Fail: true}, |
||||||
|
{name: "http call fail time", endpoint: node.HTTPAuthEndpoint(), prov: changingAuth(goodAuth, offsetTimeAuth(secret, tooLong+requestDelay)), expectCall2Fail: true}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, testCase := range testCases { |
||||||
|
t.Run(testCase.name, testCase.Run) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func noneAuth(secret [32]byte) rpc.HTTPAuth { |
||||||
|
return func(header http.Header) error { |
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{ |
||||||
|
"iat": &jwt.NumericDate{Time: time.Now()}, |
||||||
|
}) |
||||||
|
s, err := token.SignedString(secret[:]) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to create JWT token: %w", err) |
||||||
|
} |
||||||
|
header.Set("Authorization", "Bearer "+s) |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func changingAuth(provs ...rpc.HTTPAuth) rpc.HTTPAuth { |
||||||
|
i := 0 |
||||||
|
return func(header http.Header) error { |
||||||
|
i += 1 |
||||||
|
if i > len(provs) { |
||||||
|
i = len(provs) |
||||||
|
} |
||||||
|
return provs[i-1](header) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func offsetTimeAuth(secret [32]byte, offset time.Duration) rpc.HTTPAuth { |
||||||
|
return func(header http.Header) error { |
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ |
||||||
|
"iat": &jwt.NumericDate{Time: time.Now().Add(offset)}, |
||||||
|
}) |
||||||
|
s, err := token.SignedString(secret[:]) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to create JWT token: %w", err) |
||||||
|
} |
||||||
|
header.Set("Authorization", "Bearer "+s) |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,106 @@ |
|||||||
|
// Copyright 2022 The go-ethereum Authors
|
||||||
|
// This file is part of the go-ethereum library.
|
||||||
|
//
|
||||||
|
// The go-ethereum library is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// The go-ethereum library is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Lesser General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Lesser General Public License
|
||||||
|
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package rpc |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"github.com/gorilla/websocket" |
||||||
|
) |
||||||
|
|
||||||
|
// ClientOption is a configuration option for the RPC client.
|
||||||
|
type ClientOption interface { |
||||||
|
applyOption(*clientConfig) |
||||||
|
} |
||||||
|
|
||||||
|
type clientConfig struct { |
||||||
|
httpClient *http.Client |
||||||
|
httpHeaders http.Header |
||||||
|
httpAuth HTTPAuth |
||||||
|
|
||||||
|
wsDialer *websocket.Dialer |
||||||
|
} |
||||||
|
|
||||||
|
func (cfg *clientConfig) initHeaders() { |
||||||
|
if cfg.httpHeaders == nil { |
||||||
|
cfg.httpHeaders = make(http.Header) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (cfg *clientConfig) setHeader(key, value string) { |
||||||
|
cfg.initHeaders() |
||||||
|
cfg.httpHeaders.Set(key, value) |
||||||
|
} |
||||||
|
|
||||||
|
type optionFunc func(*clientConfig) |
||||||
|
|
||||||
|
func (fn optionFunc) applyOption(opt *clientConfig) { |
||||||
|
fn(opt) |
||||||
|
} |
||||||
|
|
||||||
|
// WithWebsocketDialer configures the websocket.Dialer used by the RPC client.
|
||||||
|
func WithWebsocketDialer(dialer websocket.Dialer) ClientOption { |
||||||
|
return optionFunc(func(cfg *clientConfig) { |
||||||
|
cfg.wsDialer = &dialer |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// WithHeader configures HTTP headers set by the RPC client. Headers set using this option
|
||||||
|
// will be used for both HTTP and WebSocket connections.
|
||||||
|
func WithHeader(key, value string) ClientOption { |
||||||
|
return optionFunc(func(cfg *clientConfig) { |
||||||
|
cfg.initHeaders() |
||||||
|
cfg.httpHeaders.Set(key, value) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// WithHeaders configures HTTP headers set by the RPC client. Headers set using this
|
||||||
|
// option will be used for both HTTP and WebSocket connections.
|
||||||
|
func WithHeaders(headers http.Header) ClientOption { |
||||||
|
return optionFunc(func(cfg *clientConfig) { |
||||||
|
cfg.initHeaders() |
||||||
|
for k, vs := range headers { |
||||||
|
cfg.httpHeaders[k] = vs |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// WithHTTPClient configures the http.Client used by the RPC client.
|
||||||
|
func WithHTTPClient(c *http.Client) ClientOption { |
||||||
|
return optionFunc(func(cfg *clientConfig) { |
||||||
|
cfg.httpClient = c |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// WithHTTPAuth configures HTTP request authentication. The given provider will be called
|
||||||
|
// whenever a request is made. Note that only one authentication provider can be active at
|
||||||
|
// any time.
|
||||||
|
func WithHTTPAuth(a HTTPAuth) ClientOption { |
||||||
|
if a == nil { |
||||||
|
panic("nil auth") |
||||||
|
} |
||||||
|
return optionFunc(func(cfg *clientConfig) { |
||||||
|
cfg.httpAuth = a |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// A HTTPAuth function is called by the client whenever a HTTP request is sent.
|
||||||
|
// The function must be safe for concurrent use.
|
||||||
|
//
|
||||||
|
// Usually, HTTPAuth functions will call h.Set("authorization", "...") to add
|
||||||
|
// auth information to the request.
|
||||||
|
type HTTPAuth func(h http.Header) error |
@ -0,0 +1,25 @@ |
|||||||
|
package rpc_test |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"net/http" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/rpc" |
||||||
|
) |
||||||
|
|
||||||
|
// This example configures a HTTP-based RPC client with two options - one setting the
|
||||||
|
// overall request timeout, the other adding a custom HTTP header to all requests.
|
||||||
|
func ExampleDialOptions() { |
||||||
|
tokenHeader := rpc.WithHeader("x-token", "foo") |
||||||
|
httpClient := rpc.WithHTTPClient(&http.Client{ |
||||||
|
Timeout: 10 * time.Second, |
||||||
|
}) |
||||||
|
|
||||||
|
ctx := context.Background() |
||||||
|
c, err := rpc.DialOptions(ctx, "http://rpc.example.com", httpClient, tokenHeader) |
||||||
|
if err != nil { |
||||||
|
panic(err) |
||||||
|
} |
||||||
|
c.Close() |
||||||
|
} |
Loading…
Reference in new issue