Skip to content

Commit 28897c8

Browse files
cristalolegrenaynayvgonkivs
authored
fix: jwt token nonce and expiration time (#3967)
Co-authored-by: rene <[email protected]> Co-authored-by: Viacheslav <[email protected]>
1 parent c93790e commit 28897c8

File tree

9 files changed

+127
-20
lines changed

9 files changed

+127
-20
lines changed

api/rpc/perms/permissions.go

+24-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package perms
22

33
import (
4+
"crypto/rand"
45
"encoding/json"
6+
"time"
57

68
"github.com/cristalhq/jwt/v5"
79
"github.com/filecoin-project/go-jsonrpc/auth"
@@ -19,7 +21,9 @@ var AuthKey = "Authorization"
1921
// JWTPayload is a utility struct for marshaling/unmarshalling
2022
// permissions into for token signing/verifying.
2123
type JWTPayload struct {
22-
Allow []auth.Permission
24+
Allow []auth.Permission
25+
Nonce []byte
26+
ExpiresAt time.Time
2327
}
2428

2529
func (j *JWTPayload) MarshalBinary() (data []byte, err error) {
@@ -29,8 +33,26 @@ func (j *JWTPayload) MarshalBinary() (data []byte, err error) {
2933
// NewTokenWithPerms generates and signs a new JWT token with the given secret
3034
// and given permissions.
3135
func NewTokenWithPerms(signer jwt.Signer, perms []auth.Permission) ([]byte, error) {
36+
return NewTokenWithTTL(signer, perms, 0)
37+
}
38+
39+
// NewTokenWithTTL generates and signs a new JWT token with the given secret
40+
// and given permissions and TTL.
41+
func NewTokenWithTTL(signer jwt.Signer, perms []auth.Permission, ttl time.Duration) ([]byte, error) {
42+
nonce := make([]byte, 32)
43+
if _, err := rand.Read(nonce); err != nil {
44+
return nil, err
45+
}
46+
47+
var expiresAt time.Time
48+
if ttl != 0 {
49+
expiresAt = time.Now().UTC().Add(ttl)
50+
}
51+
3252
p := &JWTPayload{
33-
Allow: perms,
53+
Allow: perms,
54+
Nonce: nonce,
55+
ExpiresAt: expiresAt,
3456
}
3557
token, err := jwt.NewBuilder(signer).Build(p)
3658
if err != nil {

api/rpc_test.go

+36
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,42 @@ func TestRPCCallsUnderlyingNode(t *testing.T) {
8989
require.Equal(t, expectedBalance, balance)
9090
}
9191

92+
func TestRPCCallsTokenExpired(t *testing.T) {
93+
ctx, cancel := context.WithCancel(context.Background())
94+
t.Cleanup(cancel)
95+
96+
// generate dummy signer and sign admin perms token with it
97+
key := make([]byte, 32)
98+
99+
signer, err := jwt.NewSignerHS(jwt.HS256, key)
100+
require.NoError(t, err)
101+
102+
verifier, err := jwt.NewVerifierHS(jwt.HS256, key)
103+
require.NoError(t, err)
104+
105+
nd, _ := setupNodeWithAuthedRPC(t, signer, verifier)
106+
url := nd.RPCServer.ListenAddr()
107+
108+
adminToken, err := perms.NewTokenWithTTL(signer, perms.AllPerms, time.Millisecond)
109+
require.NoError(t, err)
110+
111+
// we need to run this a few times to prevent the race where the server is not yet started
112+
var rpcClient *client.Client
113+
for i := 0; i < 3; i++ {
114+
time.Sleep(time.Second * 1)
115+
rpcClient, err = client.NewClient(ctx, "http://"+url, string(adminToken))
116+
if err == nil {
117+
t.Cleanup(rpcClient.Close)
118+
break
119+
}
120+
}
121+
require.NotNil(t, rpcClient)
122+
require.NoError(t, err)
123+
124+
_, err = rpcClient.State.Balance(ctx)
125+
require.ErrorContains(t, err, "request failed, http status 401 Unauthorized")
126+
}
127+
92128
// api contains all modules that are made available as the node's
93129
// public API surface
94130
type api struct {

cmd/auth.go

+13-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"path/filepath"
9+
"time"
910

1011
"github.com/cristalhq/jwt/v5"
1112
"github.com/filecoin-project/go-jsonrpc/auth"
@@ -19,6 +20,8 @@ import (
1920
nodemod "github.com/celestiaorg/celestia-node/nodebuilder/node"
2021
)
2122

23+
var ttlFlagName = "ttl"
24+
2225
func AuthCmd(fsets ...*flag.FlagSet) *cobra.Command {
2326
cmd := &cobra.Command{
2427
Use: "auth [permission-level (e.g. read || write || admin)]",
@@ -34,6 +37,11 @@ func AuthCmd(fsets ...*flag.FlagSet) *cobra.Command {
3437
return err
3538
}
3639

40+
ttl, err := cmd.Flags().GetDuration(ttlFlagName)
41+
if err != nil {
42+
return err
43+
}
44+
3745
ks, err := newKeystore(StorePath(cmd.Context()))
3846
if err != nil {
3947
return err
@@ -50,7 +58,7 @@ func AuthCmd(fsets ...*flag.FlagSet) *cobra.Command {
5058
}
5159
}
5260

53-
token, err := buildJWTToken(key.Body, permissions)
61+
token, err := buildJWTToken(key.Body, permissions, ttl)
5462
if err != nil {
5563
return err
5664
}
@@ -62,6 +70,8 @@ func AuthCmd(fsets ...*flag.FlagSet) *cobra.Command {
6270
for _, set := range fsets {
6371
cmd.Flags().AddFlagSet(set)
6472
}
73+
cmd.Flags().Duration(ttlFlagName, 0, "Set a Time-to-live (TTL) for the token")
74+
6575
return cmd
6676
}
6777

@@ -73,12 +83,12 @@ func newKeystore(path string) (keystore.Keystore, error) {
7383
return keystore.NewFSKeystore(filepath.Join(expanded, "keys"), nil)
7484
}
7585

76-
func buildJWTToken(body []byte, permissions []auth.Permission) (string, error) {
86+
func buildJWTToken(body []byte, permissions []auth.Permission, ttl time.Duration) (string, error) {
7787
signer, err := jwt.NewSignerHS(jwt.HS256, body)
7888
if err != nil {
7989
return "", err
8090
}
81-
return authtoken.NewSignedJWT(signer, permissions)
91+
return authtoken.NewSignedJWT(signer, permissions, ttl)
8292
}
8393

8494
func generateNewKey(ks keystore.Keystore) (keystore.PrivKey, error) {

cmd/rpc.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func getToken(path string) (string, error) {
128128
fmt.Printf("error getting the JWT secret: %v", err)
129129
return "", err
130130
}
131-
return buildJWTToken(key.Body, perms.AllPerms)
131+
return buildJWTToken(key.Body, perms.AllPerms, 0)
132132
}
133133

134134
type rpcClientKey struct{}

libs/authtoken/authtoken.go

+22-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package authtoken
22

33
import (
4+
"crypto/rand"
45
"encoding/json"
6+
"fmt"
7+
"time"
58

69
"github.com/cristalhq/jwt/v5"
710
"github.com/filecoin-project/go-jsonrpc/auth"
@@ -17,17 +20,32 @@ func ExtractSignedPermissions(verifier jwt.Verifier, token string) ([]auth.Permi
1720
return nil, err
1821
}
1922
p := new(perms.JWTPayload)
20-
err = json.Unmarshal(tk.Claims(), p)
21-
if err != nil {
23+
24+
if err := json.Unmarshal(tk.Claims(), p); err != nil {
2225
return nil, err
2326
}
27+
if !p.ExpiresAt.IsZero() && p.ExpiresAt.Before(time.Now().UTC()) {
28+
return nil, fmt.Errorf("token expired %s ago", time.Since(p.ExpiresAt))
29+
}
2430
return p.Allow, nil
2531
}
2632

2733
// NewSignedJWT returns a signed JWT token with the passed permissions and signer.
28-
func NewSignedJWT(signer jwt.Signer, permissions []auth.Permission) (string, error) {
34+
func NewSignedJWT(signer jwt.Signer, permissions []auth.Permission, ttl time.Duration) (string, error) {
35+
nonce := make([]byte, 32)
36+
if _, err := rand.Read(nonce); err != nil {
37+
return "", err
38+
}
39+
40+
var expiresAt time.Time
41+
if ttl != 0 {
42+
expiresAt = time.Now().UTC().Add(ttl)
43+
}
44+
2945
token, err := jwt.NewBuilder(signer).Build(&perms.JWTPayload{
30-
Allow: permissions,
46+
Allow: permissions,
47+
Nonce: nonce,
48+
ExpiresAt: expiresAt,
3149
})
3250
if err != nil {
3351
return "", err

nodebuilder/node/admin.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package node
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/cristalhq/jwt/v5"
78
"github.com/filecoin-project/go-jsonrpc/auth"
@@ -57,5 +58,11 @@ func (m *module) AuthVerify(_ context.Context, token string) ([]auth.Permission,
5758
}
5859

5960
func (m *module) AuthNew(_ context.Context, permissions []auth.Permission) (string, error) {
60-
return authtoken.NewSignedJWT(m.signer, permissions)
61+
return authtoken.NewSignedJWT(m.signer, permissions, 0)
62+
}
63+
64+
func (m *module) AuthNewWithExpiry(_ context.Context,
65+
permissions []auth.Permission, ttl time.Duration,
66+
) (string, error) {
67+
return authtoken.NewSignedJWT(m.signer, permissions, ttl)
6168
}

nodebuilder/node/cmd/node.go

+9-3
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ var authCmd = &cobra.Command{
8787
Use: "set-permissions",
8888
Args: cobra.MinimumNArgs(1),
8989
Short: "Signs and returns a new token with the given permissions.",
90-
RunE: func(c *cobra.Command, args []string) error {
91-
client, err := cmdnode.ParseClientFromCtx(c.Context())
90+
RunE: func(cmd *cobra.Command, args []string) error {
91+
client, err := cmdnode.ParseClientFromCtx(cmd.Context())
9292
if err != nil {
9393
return err
9494
}
@@ -99,7 +99,13 @@ var authCmd = &cobra.Command{
9999
perms[i] = (auth.Permission)(p)
100100
}
101101

102-
result, err := client.Node.AuthNew(c.Context(), perms)
102+
ttl, _ := cmd.Flags().GetDuration("ttl")
103+
if ttl != 0 {
104+
result, err := client.Node.AuthNewWithExpiry(cmd.Context(), perms, ttl)
105+
return cmdnode.PrintOutput(result, err, nil)
106+
}
107+
108+
result, err := client.Node.AuthNew(cmd.Context(), perms)
103109
return cmdnode.PrintOutput(result, err, nil)
104110
},
105111
}

nodebuilder/node/node.go

+13-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package node
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/filecoin-project/go-jsonrpc/auth"
78
)
@@ -24,17 +25,20 @@ type Module interface {
2425
AuthVerify(ctx context.Context, token string) ([]auth.Permission, error)
2526
// AuthNew signs and returns a new token with the given permissions.
2627
AuthNew(ctx context.Context, perms []auth.Permission) (string, error)
28+
// AuthNewWithExpiry signs and returns a new token with the given permissions and TTL.
29+
AuthNewWithExpiry(ctx context.Context, perms []auth.Permission, ttl time.Duration) (string, error)
2730
}
2831

2932
var _ Module = (*API)(nil)
3033

3134
type API struct {
3235
Internal struct {
33-
Info func(context.Context) (Info, error) `perm:"admin"`
34-
Ready func(context.Context) (bool, error) `perm:"read"`
35-
LogLevelSet func(ctx context.Context, name, level string) error `perm:"admin"`
36-
AuthVerify func(ctx context.Context, token string) ([]auth.Permission, error) `perm:"admin"`
37-
AuthNew func(ctx context.Context, perms []auth.Permission) (string, error) `perm:"admin"`
36+
Info func(context.Context) (Info, error) `perm:"admin"`
37+
Ready func(context.Context) (bool, error) `perm:"read"`
38+
LogLevelSet func(ctx context.Context, name, level string) error `perm:"admin"`
39+
AuthVerify func(ctx context.Context, token string) ([]auth.Permission, error) `perm:"admin"`
40+
AuthNew func(ctx context.Context, perms []auth.Permission) (string, error) `perm:"admin"`
41+
AuthNewWithExpiry func(ctx context.Context, perms []auth.Permission, ttl time.Duration) (string, error) `perm:"admin"`
3842
}
3943
}
4044

@@ -57,3 +61,7 @@ func (api *API) AuthVerify(ctx context.Context, token string) ([]auth.Permission
5761
func (api *API) AuthNew(ctx context.Context, perms []auth.Permission) (string, error) {
5862
return api.Internal.AuthNew(ctx, perms)
5963
}
64+
65+
func (api *API) AuthNewWithExpiry(ctx context.Context, perms []auth.Permission, ttl time.Duration) (string, error) {
66+
return api.Internal.AuthNewWithExpiry(ctx, perms, ttl)
67+
}

nodebuilder/tests/helpers_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func getAdminClient(ctx context.Context, nd *nodebuilder.Node, t *testing.T) *cl
2020
signer := nd.AdminSigner
2121
listenAddr := "ws://" + nd.RPCServer.ListenAddr()
2222

23-
jwt, err := authtoken.NewSignedJWT(signer, []auth.Permission{"public", "read", "write", "admin"})
23+
jwt, err := authtoken.NewSignedJWT(signer, []auth.Permission{"public", "read", "write", "admin"}, time.Minute)
2424
require.NoError(t, err)
2525

2626
client, err := client.NewClient(ctx, listenAddr, jwt)

0 commit comments

Comments
 (0)