diff --git a/Makefile b/Makefile index 8fd3cf705..d1940804c 100644 --- a/Makefile +++ b/Makefile @@ -156,7 +156,8 @@ lint: lint-imports ## test-unit: Run unit tests. test-unit: @echo "--> Running unit tests" - @go test $(VERBOSE) -tags=fibre -covermode=atomic -coverprofile=coverage.txt `go list ./... | grep -v nodebuilder/tests` $(LOG_AND_FILTER) +# Test-node port allocation is process-local, so coverage packages must run serially. + @go test $(VERBOSE) -p 1 -tags=fibre -covermode=atomic -coverprofile=coverage.txt `go list ./... | grep -v nodebuilder/tests` $(LOG_AND_FILTER) .PHONY: test-unit ## test-unit-fast: Run unit tests without coverage instrumentation. diff --git a/README.md b/README.md index 79dd0f243..50bce500d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,14 @@ For more information on setting up a node and the hardware requirements needed, The celestia-node public API is documented [here](https://node-rpc-docs.celestia.org/). +### Revoke an RPC token + +Use an admin token to revoke a previously issued token on a running node: + +```sh +celestia node revoke-token "$TOKEN_TO_REVOKE" --url http://localhost:26658 --token "$ADMIN_TOKEN" +``` + ## Node types - **Bridge** nodes - relay blocks from the celestia consensus network to the celestia data availability (DA) network diff --git a/api/rpc/server.go b/api/rpc/server.go index 17d031893..0128a41bc 100644 --- a/api/rpc/server.go +++ b/api/rpc/server.go @@ -75,7 +75,7 @@ type Server struct { tlsKeyPath string signer jwt.Signer - verifier jwt.Verifier + verifier *authtoken.Verifier metrics *rpcMetrics } @@ -93,7 +93,7 @@ func NewServer( tlsConfig TLSConfig, rateLimitCfg RateLimitConfig, signer jwt.Signer, - verifier jwt.Verifier, + verifier *authtoken.Verifier, ) *Server { srv := &Server{ signer: signer, @@ -181,22 +181,28 @@ func (s *Server) WithMetrics() error { return nil } -// verifyAuth is the RPC server's auth middleware. This middleware is only -// reached if a token is provided in the header of the request, otherwise only -// methods with `read` permissions are accessible. -func (s *Server) verifyAuth(_ context.Context, token string) ([]auth.Permission, error) { - if s.authDisabled { - return perms.AllPerms, nil - } - return authtoken.ExtractSignedPermissions(s.verifier, token) -} - // authHandler wraps the handler with authentication. func (s *Server) authHandler(next http.Handler) http.Handler { - return &auth.Handler{ - Verify: s.verifyAuth, - Next: next.ServeHTTP, - } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + var release func() + defer func() { + if release != nil { + release() + } + }() + handler := auth.Handler{ + Verify: func(ctx context.Context, token string) ([]auth.Permission, error) { + permissions, unregister, err := s.verifier.VerifyAndRegister(ctx, token, cancel) + release = unregister + return permissions, err + }, + Next: next.ServeHTTP, + } + // go-jsonrpc closes WebSockets and subscriptions when this context is canceled. + handler.ServeHTTP(w, r.WithContext(ctx)) + }) } // corsAny applies permissive CORS (allows all origins, methods, headers) diff --git a/api/rpc/server_test.go b/api/rpc/server_test.go index 51ed64d60..6127f705c 100644 --- a/api/rpc/server_test.go +++ b/api/rpc/server_test.go @@ -15,16 +15,20 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "testing" "time" "github.com/cristalhq/jwt/v5" "github.com/filecoin-project/go-jsonrpc" "github.com/filecoin-project/go-jsonrpc/auth" + "github.com/ipfs/go-datastore" + ds_sync "github.com/ipfs/go-datastore/sync" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/celestiaorg/celestia-node/api/rpc/perms" + "github.com/celestiaorg/celestia-node/libs/authtoken" ) // TestServer_HandlerStackSelection tests that the correct middleware stack is selected @@ -265,8 +269,8 @@ func TestServer_AuthMiddleware(t *testing.T) { } } -// TestServer_VerifyAuth tests the auth verification logic -func TestServer_VerifyAuth(t *testing.T) { +// TestServer_AuthPermissions tests permissions passed through the auth middleware. +func TestServer_AuthPermissions(t *testing.T) { signer, verifier := createTestJWT(t) tests := []struct { @@ -311,13 +315,27 @@ func TestServer_VerifyAuth(t *testing.T) { verifier, ) - permissions, err := server.verifyAuth(context.Background(), tt.token) - + called := false + handler := server.newHandlerStack(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + for _, permission := range perms.AllPerms { + assert.Equal(t, slices.Contains(tt.expectedPerms, permission), + auth.HasPerm(r.Context(), perms.AllPerms, permission)) + } + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tt.token != "" { + req.Header.Set("Authorization", "Bearer "+tt.token) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) if tt.expectError { - assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.False(t, called) } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedPerms, permissions) + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, called) } }) } @@ -507,7 +525,7 @@ func generateSelfSignedCert(t *testing.T) (certPath, keyPath string) { return certPath, keyPath } -func createTestJWT(t *testing.T) (jwt.Signer, jwt.Verifier) { +func createTestJWT(t *testing.T) (jwt.Signer, *authtoken.Verifier) { key := []byte("test-secret-key-for-jwt-signing-32") signer, err := jwt.NewSignerHS(jwt.HS256, key) require.NoError(t, err) @@ -515,7 +533,7 @@ func createTestJWT(t *testing.T) (jwt.Signer, jwt.Verifier) { verifier, err := jwt.NewVerifierHS(jwt.HS256, key) require.NoError(t, err) - return signer, verifier + return signer, authtoken.NewVerifier(verifier, ds_sync.MutexWrap(datastore.NewMapDatastore())) } func createValidToken(t *testing.T, signer jwt.Signer, permissions []auth.Permission) string { diff --git a/libs/authtoken/authtoken.go b/libs/authtoken/authtoken.go index 2e28b745d..3c6e73767 100644 --- a/libs/authtoken/authtoken.go +++ b/libs/authtoken/authtoken.go @@ -13,12 +13,16 @@ import ( ) // ExtractSignedPermissions returns the permissions granted to the token by the passed signer. -// If the token isn't signed by the signer, it will not pass verification. +// It checks signature and expiry only; use Verifier.Verify to also check revocation. func ExtractSignedPermissions(verifier jwt.Verifier, token string) ([]auth.Permission, error) { tk, err := jwt.Parse([]byte(token), verifier) if err != nil { return nil, err } + return extractPermissions(tk) +} + +func extractPermissions(tk *jwt.Token) ([]auth.Permission, error) { p := new(perms.JWTPayload) if err := json.Unmarshal(tk.Claims(), p); err != nil { diff --git a/libs/authtoken/verifier.go b/libs/authtoken/verifier.go new file mode 100644 index 000000000..25f02080d --- /dev/null +++ b/libs/authtoken/verifier.go @@ -0,0 +1,121 @@ +package authtoken + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sync" + + "github.com/cristalhq/jwt/v5" + "github.com/filecoin-project/go-jsonrpc/auth" + "github.com/ipfs/go-datastore" +) + +// ErrRevoked indicates that a token has been revoked. +var ErrRevoked = errors.New("token revoked") + +// Verifier checks JWT signatures, expiry and persistent revocations. +// Active requests are canceled when their token is revoked. +type Verifier struct { + verifier jwt.Verifier + store datastore.Datastore + mu sync.Mutex + sessions map[datastore.Key]map[context.Context]context.CancelFunc +} + +// NewVerifier uses the node's datastore to persist revocations across restarts. +func NewVerifier(verifier jwt.Verifier, store datastore.Datastore) *Verifier { + return &Verifier{ + verifier: verifier, + store: store, + sessions: make(map[datastore.Key]map[context.Context]context.CancelFunc), + } +} + +// Verify returns the permissions of a valid, unrevoked token. +func (v *Verifier) Verify(ctx context.Context, token string) ([]auth.Permission, error) { + tk, err := jwt.Parse([]byte(token), v.verifier) + if err != nil { + return nil, err + } + return v.verify(ctx, tk) +} + +// VerifyAndRegister verifies a token and cancels the request when it is revoked. +// Call the returned function when the request finishes. The context must be unique per request. +func (v *Verifier) VerifyAndRegister( + ctx context.Context, token string, cancel context.CancelFunc, +) ([]auth.Permission, func(), error) { + tk, err := jwt.Parse([]byte(token), v.verifier) + if err != nil { + return nil, nil, err + } + v.mu.Lock() + defer v.mu.Unlock() + // Checking under the same lock as Revoke closes the authentication/registration race. + permissions, err := v.verify(ctx, tk) + if err != nil { + return nil, nil, err + } + key := tokenKey(tk) + if v.sessions[key] == nil { + v.sessions[key] = make(map[context.Context]context.CancelFunc) + } + v.sessions[key][ctx] = cancel + return permissions, func() { + v.mu.Lock() + defer v.mu.Unlock() + delete(v.sessions[key], ctx) + if len(v.sessions[key]) == 0 { + delete(v.sessions, key) + } + }, nil +} + +// Revoke permanently invalidates a token signed by this node, including tokens +// issued before nonces were introduced. Repeated revocations are safe. +func (v *Verifier) Revoke(ctx context.Context, token string) error { + tk, err := jwt.Parse([]byte(token), v.verifier) + if err != nil { + return err + } + key := tokenKey(tk) + v.mu.Lock() + defer v.mu.Unlock() + if err := v.store.Put(ctx, key, nil); err != nil { + return fmt.Errorf("persist token revocation: %w", err) + } + err = v.store.Sync(ctx, key) + // Even if syncing fails, reject the token and terminate its active sessions. + for _, cancel := range v.sessions[key] { + cancel() + } + delete(v.sessions, key) + if err != nil { + return fmt.Errorf("sync token revocation: %w", err) + } + return nil +} + +func (v *Verifier) verify(ctx context.Context, tk *jwt.Token) ([]auth.Permission, error) { + permissions, err := extractPermissions(tk) + if err != nil { + return nil, err + } + revoked, err := v.store.Has(ctx, tokenKey(tk)) + if err != nil { + return nil, fmt.Errorf("check token revocation: %w", err) + } + if revoked { + return nil, ErrRevoked + } + return permissions, nil +} + +func tokenKey(tk *jwt.Token) datastore.Key { + // Hash decoded signatures so alternate base64 encodings cannot bypass revocation. + digest := sha256.Sum256(tk.Signature()) + return datastore.NewKey("/auth/revoked/" + hex.EncodeToString(digest[:])) +} diff --git a/libs/authtoken/verifier_test.go b/libs/authtoken/verifier_test.go new file mode 100644 index 000000000..57971ed9d --- /dev/null +++ b/libs/authtoken/verifier_test.go @@ -0,0 +1,262 @@ +package authtoken + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/cristalhq/jwt/v5" + "github.com/ipfs/go-datastore" + ds_sync "github.com/ipfs/go-datastore/sync" + badger "github.com/ipfs/go-ds-badger4" + "github.com/stretchr/testify/require" + + "github.com/celestiaorg/celestia-node/api/rpc/perms" +) + +func testSigner(t *testing.T) (jwt.Signer, jwt.Verifier) { + t.Helper() + key := []byte("test-secret-key-for-jwt-signing-32") + signer, err := jwt.NewSignerHS(jwt.HS256, key) + require.NoError(t, err) + verifier, err := jwt.NewVerifierHS(jwt.HS256, key) + require.NoError(t, err) + return signer, verifier +} + +func TestRevocationPersists(t *testing.T) { + ctx := context.Background() + signer, verifier := testSigner(t) + path := t.TempDir() + store, err := badger.NewDatastore(path, nil) + require.NoError(t, err) + v := NewVerifier(verifier, store) + token, err := NewSignedJWT(signer, perms.AllPerms, 0) + require.NoError(t, err) + other, err := NewSignedJWT(signer, perms.AllPerms, time.Hour) + require.NoError(t, err) + // Older tokens have no nonce or expiry. + legacy, err := jwt.NewBuilder(signer).Build(map[string]any{"Allow": perms.ReadPerms}) + require.NoError(t, err) + for _, token := range []string{token, legacy.String()} { + _, err = v.Verify(ctx, token) + require.NoError(t, err) + require.NoError(t, v.Revoke(ctx, token)) + require.NoError(t, v.Revoke(ctx, token)) + _, err = v.Verify(ctx, token) + require.ErrorIs(t, err, ErrRevoked) + } + require.NoError(t, store.Close()) + store, err = badger.NewDatastore(path, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + v = NewVerifier(verifier, store) + for _, token := range []string{token, legacy.String()} { + _, err = v.Verify(ctx, token) + require.ErrorIs(t, err, ErrRevoked) + } + allowed, err := v.Verify(ctx, other) + require.NoError(t, err) + require.Equal(t, perms.AllPerms, allowed) +} + +func TestRevocationRejectsInvalidTokens(t *testing.T) { + ctx := context.Background() + signer, verifier := testSigner(t) + v := NewVerifier(verifier, ds_sync.MutexWrap(datastore.NewMapDatastore())) + foreignSigner, err := jwt.NewSignerHS(jwt.HS256, []byte("another-secret-key-for-signing-32")) + require.NoError(t, err) + foreign, err := NewSignedJWT(foreignSigner, perms.AllPerms, 0) + require.NoError(t, err) + for _, token := range []string{"", "invalid", foreign} { + require.Error(t, v.Revoke(ctx, token)) + _, err := v.Verify(ctx, token) + require.Error(t, err) + _, release, err := v.VerifyAndRegister(ctx, token, func() {}) + require.Error(t, err) + require.Nil(t, release) + } + expired, err := NewSignedJWT(signer, perms.AllPerms, -time.Hour) + require.NoError(t, err) + _, err = v.Verify(ctx, expired) + require.ErrorContains(t, err, "token expired") + _, release, err := v.VerifyAndRegister(ctx, expired, func() {}) + require.ErrorContains(t, err, "token expired") + require.Nil(t, release) + require.Empty(t, v.sessions) + require.NoError(t, v.Revoke(ctx, expired)) +} + +func TestRevocationAlternateSignatureEncoding(t *testing.T) { + ctx := context.Background() + signer, verifier := testSigner(t) + v := NewVerifier(verifier, ds_sync.MutexWrap(datastore.NewMapDatastore())) + token, err := NewSignedJWT(signer, perms.AllPerms, 0) + require.NoError(t, err) + // HS256 leaves two unused bits in the last base64 character. + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + last := strings.IndexByte(alphabet, token[len(token)-1]) + alternate := token[:len(token)-1] + string(alphabet[last+1]) + _, err = v.Verify(ctx, alternate) + require.NoError(t, err) + require.NoError(t, v.Revoke(ctx, token)) + _, err = v.Verify(ctx, alternate) + require.ErrorIs(t, err, ErrRevoked) +} + +type failingStore struct { + datastore.Datastore + hasErr, putErr, syncErr error +} + +func (s failingStore) Has(ctx context.Context, key datastore.Key) (bool, error) { + if s.hasErr != nil { + return false, s.hasErr + } + return s.Datastore.Has(ctx, key) +} + +func (s failingStore) Put(ctx context.Context, key datastore.Key, value []byte) error { + if s.putErr != nil { + return s.putErr + } + return s.Datastore.Put(ctx, key, value) +} + +func (s failingStore) Sync(ctx context.Context, key datastore.Key) error { + if s.syncErr != nil { + return s.syncErr + } + return s.Datastore.Sync(ctx, key) +} + +func TestRevocationStoreErrors(t *testing.T) { + signer, verifier := testSigner(t) + token, err := NewSignedJWT(signer, perms.AllPerms, 0) + require.NoError(t, err) + failure := errors.New("store unavailable") + for _, operation := range []string{"read", "write", "sync"} { + t.Run(operation, func(t *testing.T) { + store := &failingStore{Datastore: ds_sync.MutexWrap(datastore.NewMapDatastore())} + v := NewVerifier(verifier, store) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _, release, err := v.VerifyAndRegister(ctx, token, cancel) + require.NoError(t, err) + defer release() + switch operation { + case "read": + store.hasErr = failure + _, err = v.Verify(ctx, token) + require.ErrorIs(t, err, failure) + _, _, err = v.VerifyAndRegister(ctx, token, cancel) + require.ErrorIs(t, err, failure) + case "write": + store.putErr = failure + require.ErrorIs(t, v.Revoke(ctx, token), failure) + require.NoError(t, ctx.Err()) + _, err = v.Verify(ctx, token) + require.NoError(t, err) + case "sync": + store.syncErr = failure + require.ErrorIs(t, v.Revoke(ctx, token), failure) + require.ErrorIs(t, ctx.Err(), context.Canceled) + _, err = v.Verify(context.Background(), token) + require.ErrorIs(t, err, ErrRevoked) + } + }) + } +} + +func TestRevokeDuringRegistration(t *testing.T) { + signer, verifier := testSigner(t) + v := NewVerifier(verifier, ds_sync.MutexWrap(datastore.NewMapDatastore())) + for range 50 { + token, err := NewSignedJWT(signer, perms.AllPerms, 0) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + var revokeErr error + go func() { + defer wg.Done() + <-start + revokeErr = v.Revoke(context.Background(), token) + }() + close(start) + _, release, err := v.VerifyAndRegister(ctx, token, cancel) + wg.Wait() + require.NoError(t, revokeErr) + if err == nil { + require.ErrorIs(t, ctx.Err(), context.Canceled) + release() + } else { + require.ErrorIs(t, err, ErrRevoked) + } + cancel() + } + require.Empty(t, v.sessions) +} + +func TestRegistrationRelease(t *testing.T) { + signer, verifier := testSigner(t) + v := NewVerifier(verifier, ds_sync.MutexWrap(datastore.NewMapDatastore())) + token, err := NewSignedJWT(signer, perms.AllPerms, 0) + require.NoError(t, err) + first, cancelFirst := context.WithCancel(context.Background()) + defer cancelFirst() + second, cancelSecond := context.WithCancel(context.Background()) + defer cancelSecond() + _, releaseFirst, err := v.VerifyAndRegister(first, token, cancelFirst) + require.NoError(t, err) + _, releaseSecond, err := v.VerifyAndRegister(second, token, cancelSecond) + require.NoError(t, err) + defer releaseSecond() + releaseFirst() + require.NoError(t, v.Revoke(context.Background(), token)) + require.NoError(t, first.Err(), "finished requests must be unregistered") + require.ErrorIs(t, second.Err(), context.Canceled) + require.Empty(t, v.sessions) +} + +// countingVerifier tracks signature checks, which jwt.Parse performs once per parse. +type countingVerifier struct { + jwt.Verifier + calls int +} + +func (v *countingVerifier) Verify(token *jwt.Token) error { + v.calls++ + return v.Verifier.Verify(token) +} + +func TestVerifierParsesOnce(t *testing.T) { + signer, verifier := testSigner(t) + counter := &countingVerifier{Verifier: verifier} + v := NewVerifier(counter, ds_sync.MutexWrap(datastore.NewMapDatastore())) + token, err := NewSignedJWT(signer, perms.ReadPerms, 0) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + allowed, err := v.Verify(ctx, token) + require.NoError(t, err) + require.Equal(t, perms.ReadPerms, allowed) + require.Equal(t, 1, counter.calls) + + counter.calls = 0 + allowed, release, err := v.VerifyAndRegister(ctx, token, cancel) + require.NoError(t, err) + defer release() + require.Equal(t, perms.ReadPerms, allowed) + require.Equal(t, 1, counter.calls) + + counter.calls = 0 + require.NoError(t, v.Revoke(context.Background(), token)) + require.Equal(t, 1, counter.calls) + require.ErrorIs(t, ctx.Err(), context.Canceled) +} diff --git a/nodebuilder/node/admin.go b/nodebuilder/node/admin.go index d8b2e10a7..721c5e4b3 100644 --- a/nodebuilder/node/admin.go +++ b/nodebuilder/node/admin.go @@ -16,10 +16,10 @@ var APIVersion = GetBuildInfo().SemanticVersion type module struct { tp Type signer jwt.Signer - verifier jwt.Verifier + verifier *authtoken.Verifier } -func newModule(tp Type, signer jwt.Signer, verifier jwt.Verifier) Module { +func newModule(tp Type, signer jwt.Signer, verifier *authtoken.Verifier) Module { return &module{ tp: tp, signer: signer, @@ -53,8 +53,8 @@ func (m *module) LogLevelSet(_ context.Context, name, level string) error { return logging.SetLogLevel(name, level) } -func (m *module) AuthVerify(_ context.Context, token string) ([]auth.Permission, error) { - return authtoken.ExtractSignedPermissions(m.verifier, token) +func (m *module) AuthVerify(ctx context.Context, token string) ([]auth.Permission, error) { + return m.verifier.Verify(ctx, token) } func (m *module) AuthNew(_ context.Context, permissions []auth.Permission) (string, error) { @@ -66,3 +66,7 @@ func (m *module) AuthNewWithExpiry(_ context.Context, ) (string, error) { return authtoken.NewSignedJWT(m.signer, permissions, ttl) } + +func (m *module) AuthRevoke(ctx context.Context, token string) error { + return m.verifier.Revoke(ctx, token) +} diff --git a/nodebuilder/node/admin_test.go b/nodebuilder/node/admin_test.go new file mode 100644 index 000000000..0cb399869 --- /dev/null +++ b/nodebuilder/node/admin_test.go @@ -0,0 +1,123 @@ +package node + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/cristalhq/jwt/v5" + "github.com/filecoin-project/go-jsonrpc" + "github.com/ipfs/go-datastore" + ds_sync "github.com/ipfs/go-datastore/sync" + "github.com/stretchr/testify/require" + + "github.com/celestiaorg/celestia-node/api/rpc" + "github.com/celestiaorg/celestia-node/api/rpc/perms" + "github.com/celestiaorg/celestia-node/libs/authtoken" +) + +func TestAuthRevokeRPC(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + key := []byte("test-secret-key-for-jwt-signing-32") + signer, err := jwt.NewSignerHS(jwt.HS256, key) + require.NoError(t, err) + jwtVerifier, err := jwt.NewVerifierHS(jwt.HS256, key) + require.NoError(t, err) + verifier := authtoken.NewVerifier(jwtVerifier, ds_sync.MutexWrap(datastore.NewMapDatastore())) + module := newModule(Light, signer, verifier) + server := rpc.NewServer("127.0.0.1", "0", false, rpc.CORSConfig{}, rpc.TLSConfig{}, + rpc.RateLimitConfig{}, signer, verifier) + server.RegisterService("node", module, &API{}) + server.RegisterService("test", revocationSubscription{}, &revocationSubscriptionAPI{}) + require.NoError(t, server.WithMetrics()) + require.NoError(t, server.Start(ctx)) + t.Cleanup(func() { require.NoError(t, server.Stop(context.Background())) }) + + adminToken, err := module.AuthNew(ctx, perms.AllPerms) + require.NoError(t, err) + targetToken, err := module.AuthNewWithExpiry(ctx, perms.ReadPerms, time.Hour) + require.NoError(t, err) + unaffectedToken, err := authtoken.NewSignedJWT(signer, perms.ReadPerms, 0) + require.NoError(t, err) + connect := func(token string) *API { + t.Helper() + var api API + closer, err := jsonrpc.NewClient(ctx, "http://"+server.ListenAddr(), "node", &api.Internal, + http.Header{"Authorization": []string{"Bearer " + token}}) + require.NoError(t, err) + t.Cleanup(closer) + return &api + } + admin := connect(adminToken) + target := connect(targetToken) + unaffected := connect(unaffectedToken) + ready, err := target.Ready(ctx) + require.NoError(t, err) + require.True(t, ready) + require.ErrorContains(t, target.AuthRevoke(ctx, adminToken), "missing permission") + + var subscription struct { + Updates func(context.Context) (<-chan int, error) + } + // Query-string authentication is supported for browser WebSocket clients too. + closer, err := jsonrpc.NewClient(ctx, "ws://"+server.ListenAddr()+"?token="+targetToken, + "test", &subscription, nil) + require.NoError(t, err) + t.Cleanup(closer) + updates, err := subscription.Updates(ctx) + require.NoError(t, err) + select { + case value := <-updates: + require.Equal(t, 1, value) + case <-ctx.Done(): + t.Fatal("subscription did not start") + } + require.NoError(t, admin.AuthRevoke(ctx, targetToken)) + require.NoError(t, admin.AuthRevoke(ctx, targetToken)) + select { + case _, ok := <-updates: + require.False(t, ok, "revocation must close the existing subscription") + case <-ctx.Done(): + t.Fatal("subscription remained open after revocation") + } + _, err = jsonrpc.NewClient(ctx, "ws://"+server.ListenAddr()+"?token="+targetToken, + "test", &subscription, nil) + require.Error(t, err, "revoked token must not reconnect") + _, err = target.Ready(ctx) + require.Error(t, err) + _, err = admin.AuthVerify(ctx, targetToken) + require.ErrorContains(t, err, "token revoked") + require.Error(t, target.AuthRevoke(ctx, adminToken)) + ready, err = unaffected.Ready(ctx) + require.NoError(t, err) + require.True(t, ready) + _, err = admin.Info(ctx) + require.NoError(t, err) + require.NoError(t, admin.AuthRevoke(ctx, adminToken)) + _, err = admin.Info(ctx) + require.Error(t, err) +} + +type revocationSubscription struct{} + +func (revocationSubscription) Updates(ctx context.Context) (<-chan int, error) { + ch := make(chan int, 1) + ch <- 1 + go func() { + <-ctx.Done() + close(ch) + }() + return ch, nil +} + +type revocationSubscriptionAPI struct { + Internal struct { + Updates func(context.Context) (<-chan int, error) `perm:"read"` + } +} + +func (a *revocationSubscriptionAPI) Updates(ctx context.Context) (<-chan int, error) { + return a.Internal.Updates(ctx) +} diff --git a/nodebuilder/node/cmd/node.go b/nodebuilder/node/cmd/node.go index cbb2ad8b3..d7de797b9 100644 --- a/nodebuilder/node/cmd/node.go +++ b/nodebuilder/node/cmd/node.go @@ -11,7 +11,7 @@ import ( ) func init() { - Cmd.AddCommand(nodeInfoCmd, logCmd, verifyCmd, authCmd) + Cmd.AddCommand(nodeInfoCmd, logCmd, verifyCmd, authCmd, revokeCmd) } var Cmd = &cobra.Command{ @@ -109,3 +109,19 @@ var authCmd = &cobra.Command{ return cmdnode.PrintOutput(result, err, nil) }, } + +var revokeCmd = &cobra.Command{ + Use: "revoke-token ", + Args: cobra.ExactArgs(1), + Short: "Permanently revokes a JWT token on the running node.", + Long: "Permanently revokes a JWT token and disconnects its active WebSocket sessions. " + + "Requires admin permission. Other tokens remain valid.", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cmdnode.ParseClientFromCtx(cmd.Context()) + if err != nil { + return err + } + defer client.Close() + return client.Node.AuthRevoke(cmd.Context(), args[0]) + }, +} diff --git a/nodebuilder/node/cmd/node_test.go b/nodebuilder/node/cmd/node_test.go new file mode 100644 index 000000000..4e352f991 --- /dev/null +++ b/nodebuilder/node/cmd/node_test.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/cristalhq/jwt/v5" + "github.com/golang/mock/gomock" + "github.com/ipfs/go-datastore" + ds_sync "github.com/ipfs/go-datastore/sync" + "github.com/stretchr/testify/require" + + "github.com/celestiaorg/celestia-node/api/rpc" + "github.com/celestiaorg/celestia-node/api/rpc/perms" + cmdnode "github.com/celestiaorg/celestia-node/cmd" + "github.com/celestiaorg/celestia-node/libs/authtoken" + "github.com/celestiaorg/celestia-node/nodebuilder/node" + "github.com/celestiaorg/celestia-node/nodebuilder/node/mocks" +) + +func TestRevokeTokenCommand(t *testing.T) { + ctx := context.Background() + key := []byte("test-secret-key-for-jwt-signing-32") + signer, err := jwt.NewSignerHS(jwt.HS256, key) + require.NoError(t, err) + jwtVerifier, err := jwt.NewVerifierHS(jwt.HS256, key) + require.NoError(t, err) + verifier := authtoken.NewVerifier(jwtVerifier, ds_sync.MutexWrap(datastore.NewMapDatastore())) + admin, err := authtoken.NewSignedJWT(signer, perms.AllPerms, 0) + require.NoError(t, err) + target, err := authtoken.NewSignedJWT(signer, perms.ReadPerms, 0) + require.NoError(t, err) + module := mocks.NewMockModule(gomock.NewController(t)) + module.EXPECT().AuthRevoke(gomock.Any(), target).Return(nil) + server := rpc.NewServer("127.0.0.1", "0", false, rpc.CORSConfig{}, rpc.TLSConfig{}, + rpc.RateLimitConfig{}, signer, verifier) + server.RegisterService("node", module, &node.API{}) + require.NoError(t, server.Start(ctx)) + t.Cleanup(func() { require.NoError(t, server.Stop(ctx)) }) + Cmd.PersistentFlags().AddFlagSet(cmdnode.RPCFlags()) + Cmd.SetArgs([]string{"revoke-token", target, "--url", "http://" + server.ListenAddr(), "--token", admin}) + require.NoError(t, Cmd.ExecuteContext(ctx)) +} diff --git a/nodebuilder/node/mocks/api.go b/nodebuilder/node/mocks/api.go index fcacbb94b..69a84fc65 100644 --- a/nodebuilder/node/mocks/api.go +++ b/nodebuilder/node/mocks/api.go @@ -67,6 +67,20 @@ func (mr *MockModuleMockRecorder) AuthNewWithExpiry(arg0, arg1, arg2 interface{} return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthNewWithExpiry", reflect.TypeOf((*MockModule)(nil).AuthNewWithExpiry), arg0, arg1, arg2) } +// AuthRevoke mocks base method. +func (m *MockModule) AuthRevoke(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthRevoke", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// AuthRevoke indicates an expected call of AuthRevoke. +func (mr *MockModuleMockRecorder) AuthRevoke(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthRevoke", reflect.TypeOf((*MockModule)(nil).AuthRevoke), arg0, arg1) +} + // AuthVerify mocks base method. func (m *MockModule) AuthVerify(arg0 context.Context, arg1 string) ([]auth.Permission, error) { m.ctrl.T.Helper() diff --git a/nodebuilder/node/module.go b/nodebuilder/node/module.go index ceff5d9b9..4108fb739 100644 --- a/nodebuilder/node/module.go +++ b/nodebuilder/node/module.go @@ -2,15 +2,21 @@ package node import ( "github.com/cristalhq/jwt/v5" + "github.com/ipfs/go-datastore" "go.uber.org/fx" + + "github.com/celestiaorg/celestia-node/libs/authtoken" ) func ConstructModule(tp Type) fx.Option { return fx.Module( "node", - fx.Provide(func(signer jwt.Signer, verifier jwt.Verifier) Module { + fx.Provide(func(signer jwt.Signer, verifier *authtoken.Verifier) Module { return newModule(tp, signer, verifier) }), fx.Provide(jwtSignerAndVerifier), + fx.Provide(func(verifier jwt.Verifier, store datastore.Batching) *authtoken.Verifier { + return authtoken.NewVerifier(verifier, store) + }), ) } diff --git a/nodebuilder/node/node.go b/nodebuilder/node/node.go index b146a7a40..86594240e 100644 --- a/nodebuilder/node/node.go +++ b/nodebuilder/node/node.go @@ -23,6 +23,8 @@ type Module interface { // AuthVerify returns the permissions assigned to the given token. AuthVerify(ctx context.Context, token string) ([]auth.Permission, error) + // AuthRevoke permanently revokes a token and cancels its active requests. + AuthRevoke(ctx context.Context, token string) error // AuthNew signs and returns a new token with the given permissions. AuthNew(ctx context.Context, perms []auth.Permission) (string, error) // AuthNewWithExpiry signs and returns a new token with the given permissions and TTL. @@ -36,6 +38,7 @@ type API struct { Info func(context.Context) (Info, error) `perm:"admin"` Ready func(context.Context) (bool, error) `perm:"read"` LogLevelSet func(ctx context.Context, name, level string) error `perm:"admin"` + AuthRevoke func(ctx context.Context, token string) error `perm:"admin"` AuthVerify func(ctx context.Context, token string) ([]auth.Permission, error) `perm:"admin"` AuthNew func(ctx context.Context, perms []auth.Permission) (string, error) `perm:"admin"` AuthNewWithExpiry func(ctx context.Context, perms []auth.Permission, ttl time.Duration) (string, error) `perm:"admin"` @@ -65,3 +68,7 @@ func (api *API) AuthNew(ctx context.Context, perms []auth.Permission) (string, e func (api *API) AuthNewWithExpiry(ctx context.Context, perms []auth.Permission, ttl time.Duration) (string, error) { return api.Internal.AuthNewWithExpiry(ctx, perms, ttl) } + +func (api *API) AuthRevoke(ctx context.Context, token string) error { + return api.Internal.AuthRevoke(ctx, token) +} diff --git a/nodebuilder/rpc/constructors.go b/nodebuilder/rpc/constructors.go index 37eba3e5c..9706923c0 100644 --- a/nodebuilder/rpc/constructors.go +++ b/nodebuilder/rpc/constructors.go @@ -4,6 +4,7 @@ import ( "github.com/cristalhq/jwt/v5" "github.com/celestiaorg/celestia-node/api/rpc" + "github.com/celestiaorg/celestia-node/libs/authtoken" "github.com/celestiaorg/celestia-node/nodebuilder/blob" "github.com/celestiaorg/celestia-node/nodebuilder/blobstream" "github.com/celestiaorg/celestia-node/nodebuilder/das" @@ -39,7 +40,7 @@ func registerEndpoints( serv.RegisterService("fibre", fibreMod, &fibre.API{}) } -func server(cfg *Config, signer jwt.Signer, verifier jwt.Verifier) *rpc.Server { +func server(cfg *Config, signer jwt.Signer, verifier *authtoken.Verifier) *rpc.Server { return rpc.NewServer(cfg.Address, cfg.Port, cfg.SkipAuth, rpc.CORSConfig{ Enabled: cfg.CORS.Enabled, AllowedOrigins: cfg.CORS.AllowedOrigins, diff --git a/nodebuilder/tests/tastora/go.sum b/nodebuilder/tests/tastora/go.sum index 2fb1247d1..89378c481 100644 --- a/nodebuilder/tests/tastora/go.sum +++ b/nodebuilder/tests/tastora/go.sum @@ -673,6 +673,8 @@ github.com/ipfs/go-cid v0.6.2 h1:VuGwJd+KJTaMJ4S4d5EEf9SXc17YUblS5axCbocn9YE= github.com/ipfs/go-cid v0.6.2/go.mod h1:Xhwg8NzHeK9xPCEZkCw4idzPiuNMpX3fARuI5Iwj1Lo= github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= +github.com/ipfs/go-ds-badger4 v0.1.8 h1:frNczf5CjCVm62RJ5mW5tD/oLQY/9IKAUpKviRV9QAI= +github.com/ipfs/go-ds-badger4 v0.1.8/go.mod h1:FdqSLA5TMsyqooENB/Hf4xzYE/iH0z/ErLD6ogtfMrA= github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ= github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw= github.com/ipfs/go-ipfs-pq v0.0.3 h1:YpoHVJB+jzK15mr/xsWC574tyDLkezVrDNeaalQBsTE=