Skip to content

op-service: add support for enable/disable JWT auth per RPC handler route #15566

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions op-service/rpc/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,16 @@ func (b *Handler) AddHandler(path string, handler http.Handler) {
// Once the route is added, RPC namespaces can be registered with AddAPIToRPC.
// The route must not have a "/" suffix, since the trailing "/" is ambiguous.
func (b *Handler) AddRPC(route string) error {
return b.AddRPCWithAuthentication(route, nil)
}

// AddRPCWithAuthentication creates a default RPC handler at the given route,
// with explicit authentication settings:
// 1. If isAuthenticated is nil, the global presence of a JWT secret will be used to determine
// if the RPC is authenticated.
// 2. If isAuthenticated is false, no authentication will be used.
// 3. If isAuthenticated is true, the RPC will be authenticated, provided a global JWT secret has been set.
func (b *Handler) AddRPCWithAuthentication(route string, isAuthenticated *bool) error {
b.rpcRoutesLock.Lock()
defer b.rpcRoutesLock.Unlock()
if strings.HasSuffix(route, "/") {
Expand Down Expand Up @@ -153,12 +163,23 @@ func (b *Handler) AddRPC(route string) error {
http.NotFound(writer, request)
})

// conditionaly set the jwt secret from global jwt secret, based on the authentication setting
var jwtSecret []byte
if isAuthenticated == nil {
jwtSecret = b.jwtSecret
} else if *isAuthenticated {
if len(b.jwtSecret) == 0 {
b.log.Warn("JWT secret is not set, but authentication is explicitly required for this RPC")
}
jwtSecret = b.jwtSecret
}

// serve RPC on configured RPC path (but not on arbitrary paths)
handler = b.newHttpRPCMiddleware(srv, handler)
handler = b.newHttpRPCMiddleware(srv, handler, jwtSecret)

// Conditionally enable Websocket support.
if b.wsEnabled { // prioritize WS RPC, if it's an upgrade request
handler = b.newWsMiddleWare(srv, handler)
handler = b.newWsMiddleWare(srv, handler, jwtSecret)
}

// Apply user middlewares
Expand Down Expand Up @@ -189,10 +210,10 @@ func (b *Handler) newHealthMiddleware(next http.Handler) http.Handler {
})
}

func (b *Handler) newHttpRPCMiddleware(server *rpc.Server, next http.Handler) http.Handler {
// Only allow RPC handlers behind the appropriate CORS / vhost / JWT (optional) setup.
func (b *Handler) newHttpRPCMiddleware(server *rpc.Server, next http.Handler, jwtSecret []byte) http.Handler {
// Only allow RPC handlers behind the appropriate CORS / vhost / JWT setup.
// Note that websockets have their own handler-stack, also configured with CORS and JWT, separately.
httpHandler := node.NewHTTPHandlerStack(server, b.corsHosts, b.vHosts, b.jwtSecret)
httpHandler := node.NewHTTPHandlerStack(server, b.corsHosts, b.vHosts, jwtSecret)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// URL is already stripped with http.StripPrefix
if r.URL.Path == "" {
Expand All @@ -203,8 +224,8 @@ func (b *Handler) newHttpRPCMiddleware(server *rpc.Server, next http.Handler) ht
})
}

func (b *Handler) newWsMiddleWare(server *rpc.Server, next http.Handler) http.Handler {
wsHandler := node.NewWSHandlerStack(server.WebsocketHandler(b.corsHosts), b.jwtSecret)
func (b *Handler) newWsMiddleWare(server *rpc.Server, next http.Handler, jwtSecret []byte) http.Handler {
wsHandler := node.NewWSHandlerStack(server.WebsocketHandler(b.corsHosts), jwtSecret)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// URL is already stripped with http.StripPrefix
if isWebsocket(r) && (r.URL.Path == "" || r.URL.Path == "ws" || r.URL.Path == "ws/") {
Expand Down
83 changes: 81 additions & 2 deletions op-service/rpc/handler_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
package rpc

import (
"context"
"crypto/rand"
"io"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/ethereum-optimism/optimism/op-service/client"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum/go-ethereum/log"
gn "github.com/ethereum/go-ethereum/node"
"github.com/ethereum/go-ethereum/rpc"

"github.com/ethereum-optimism/optimism/op-service/testlog"
)

func TestHandler(t *testing.T) {
Expand All @@ -29,3 +35,76 @@ func TestHandler(t *testing.T) {

// WS-RPC / HTTP-RPC / health are tested in server_test.go
}

func TestHandlerAuthentication(t *testing.T) {
logger := testlog.Logger(t, log.LevelInfo)

// generate JWT Secret
var jwtSecret eth.Bytes32
_, err := io.ReadFull(rand.Reader, jwtSecret[:])
require.NoError(t, err)

server := ServerFromConfig(&ServerConfig{
RpcOptions: []Option{
WithLogger(logger),
WithWebsocketEnabled(),
WithJWTSecret(jwtSecret[:]),
},
Host: "127.0.0.1",
Port: 0,
AppVersion: "test",
})

namespace := "test"
server.AddAPI(rpc.API{
Namespace: namespace,
Service: new(testAPI),
})

isAuthenticated := false
require.NoError(t, server.Handler.AddRPCWithAuthentication("/public", &isAuthenticated))
require.NoError(t, server.AddAPIToRPC("/public", rpc.API{
Namespace: namespace,
Service: new(testAPI),
}))
require.NoError(t, server.Start(), "must start")

t.Cleanup(func() {
err := server.Stop()
if err != nil {
panic(err)
}
})

endpoint := "http://" + server.Endpoint()
publicClient, err := rpc.Dial(endpoint + "/public")
require.NoError(t, err)
t.Cleanup(publicClient.Close)

defaultUnauthenticatedClient, err := rpc.Dial(endpoint)
require.NoError(t, err)
t.Cleanup(defaultUnauthenticatedClient.Close)

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
defaultAuthenticatedClient, err := client.NewRPC(ctx, logger, endpoint, client.WithGethRPCOptions(rpc.WithHTTPAuth(gn.NewJWTAuth(jwtSecret))))
require.NoError(t, err)
t.Cleanup(defaultAuthenticatedClient.Close)

t.Run("public RPC", func(t *testing.T) {
var res int
require.NoError(t, publicClient.Call(&res, namespace+"_frobnicate", 2))
require.Equal(t, 4, res)
})

t.Run("default RPC - unauthenticated", func(t *testing.T) {
var res int
require.ErrorContains(t, defaultUnauthenticatedClient.Call(&res, namespace+"_frobnicate", 2), "missing token")
})

t.Run("default RPC - authenticated", func(t *testing.T) {
var res int
require.NoError(t, defaultAuthenticatedClient.CallContext(ctx, &res, namespace+"_frobnicate", 6))
require.Equal(t, 12, res)
})
}