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