Implement JWT token auth with transparent auto-renewal.
Replace per-call SSH signing with a two-layer auth system: Server: AuthInterceptor verifies JWT tokens (HMAC-SHA256 signed with repo-local jwt.key). Authenticate RPC accepts SSH-signed challenges and issues 30-day JWTs. Expired-but-valid tokens return a ReauthChallenge in error details (server-provided nonce for fast re-auth). Authenticate RPC is exempt from token requirement. Client: TokenCredentials replaces SSHCredentials as the primary PerRPCCredentials. NewWithAuth creates clients with auto-renewal — EnsureAuth obtains initial token, retryOnAuth catches Unauthenticated errors and re-authenticates transparently. Token cached at $XDG_STATE_HOME/sgard/token (0600). CLI: dialRemote() helper handles token loading, connection setup, and initial auth. Push/pull/prune commands simplified to use it. Proto: Added Authenticate RPC, AuthenticateRequest/Response, ReauthChallenge messages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
112
client/client.go
112
client/client.go
@@ -10,28 +10,103 @@ import (
|
||||
"github.com/kisom/sgard/garden"
|
||||
"github.com/kisom/sgard/server"
|
||||
"github.com/kisom/sgard/sgardpb"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const chunkSize = 64 * 1024 // 64 KiB
|
||||
|
||||
// Client wraps a gRPC connection to a GardenSync server.
|
||||
type Client struct {
|
||||
rpc sgardpb.GardenSyncClient
|
||||
rpc sgardpb.GardenSyncClient
|
||||
creds *TokenCredentials // may be nil (no auth)
|
||||
signer ssh.Signer // may be nil (no auth)
|
||||
}
|
||||
|
||||
// New creates a Client from an existing gRPC connection.
|
||||
// New creates a Client from an existing gRPC connection (no auth).
|
||||
func New(conn grpc.ClientConnInterface) *Client {
|
||||
return &Client{rpc: sgardpb.NewGardenSyncClient(conn)}
|
||||
}
|
||||
|
||||
// NewWithAuth creates a Client with token-based auth and auto-renewal.
|
||||
// Loads any cached token automatically.
|
||||
func NewWithAuth(conn grpc.ClientConnInterface, creds *TokenCredentials, signer ssh.Signer) *Client {
|
||||
return &Client{
|
||||
rpc: sgardpb.NewGardenSyncClient(conn),
|
||||
creds: creds,
|
||||
signer: signer,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureAuth ensures the client has a valid token. If no token is cached,
|
||||
// authenticates with the server using the SSH signer.
|
||||
func (c *Client) EnsureAuth(ctx context.Context) error {
|
||||
if c.creds == nil || c.signer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we already have a token, assume it's valid until the server says otherwise.
|
||||
md, _ := c.creds.GetRequestMetadata(ctx)
|
||||
if md != nil && md["x-sgard-auth-token"] != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// No token — do full auth.
|
||||
return c.authenticate(ctx, nil)
|
||||
}
|
||||
|
||||
// authenticate calls the Authenticate RPC and caches the resulting token.
|
||||
func (c *Client) authenticate(ctx context.Context, challenge *sgardpb.ReauthChallenge) error {
|
||||
token, err := Authenticate(ctx, c.rpc, c.signer, challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.creds.SetToken(token)
|
||||
_ = SaveToken(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// retryOnAuth retries a function once after re-authenticating if it fails
|
||||
// with Unauthenticated.
|
||||
func (c *Client) retryOnAuth(ctx context.Context, fn func() error) error {
|
||||
err := fn()
|
||||
if err == nil || c.signer == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok || st.Code() != codes.Unauthenticated {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract reauth challenge if present (fast path).
|
||||
challenge := ExtractReauthChallenge(err)
|
||||
if authErr := c.authenticate(ctx, challenge); authErr != nil {
|
||||
return fmt.Errorf("re-authentication failed: %w", authErr)
|
||||
}
|
||||
|
||||
// Retry the original call.
|
||||
return fn()
|
||||
}
|
||||
|
||||
// Push sends the local manifest and any missing blobs to the server.
|
||||
// Returns the number of blobs sent, or an error. If the server is newer,
|
||||
// returns ErrServerNewer.
|
||||
// returns ErrServerNewer. Automatically re-authenticates if the token expires.
|
||||
func (c *Client) Push(ctx context.Context, g *garden.Garden) (int, error) {
|
||||
var result int
|
||||
err := c.retryOnAuth(ctx, func() error {
|
||||
n, err := c.doPush(ctx, g)
|
||||
result = n
|
||||
return err
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *Client) doPush(ctx context.Context, g *garden.Garden) (int, error) {
|
||||
localManifest := g.GetManifest()
|
||||
|
||||
// Step 1: send manifest, get decision.
|
||||
resp, err := c.rpc.PushManifest(ctx, &sgardpb.PushManifestRequest{
|
||||
Manifest: server.ManifestToProto(localManifest),
|
||||
})
|
||||
@@ -110,9 +185,18 @@ func (c *Client) Push(ctx context.Context, g *garden.Garden) (int, error) {
|
||||
|
||||
// Pull downloads the server's manifest and any missing blobs to the local garden.
|
||||
// Returns the number of blobs received, or an error. If the local manifest is
|
||||
// newer or equal, returns 0 with no error.
|
||||
// newer or equal, returns 0 with no error. Automatically re-authenticates if needed.
|
||||
func (c *Client) Pull(ctx context.Context, g *garden.Garden) (int, error) {
|
||||
// Step 1: get server manifest.
|
||||
var result int
|
||||
err := c.retryOnAuth(ctx, func() error {
|
||||
n, err := c.doPull(ctx, g)
|
||||
result = n
|
||||
return err
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *Client) doPull(ctx context.Context, g *garden.Garden) (int, error) {
|
||||
pullResp, err := c.rpc.PullManifest(ctx, &sgardpb.PullManifestRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("pull manifest: %w", err)
|
||||
@@ -190,12 +274,18 @@ func (c *Client) Pull(ctx context.Context, g *garden.Garden) (int, error) {
|
||||
}
|
||||
|
||||
// Prune requests the server to remove orphaned blobs. Returns the count removed.
|
||||
// Automatically re-authenticates if needed.
|
||||
func (c *Client) Prune(ctx context.Context) (int, error) {
|
||||
resp, err := c.rpc.Prune(ctx, &sgardpb.PruneRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune: %w", err)
|
||||
}
|
||||
return int(resp.BlobsRemoved), nil
|
||||
var result int
|
||||
err := c.retryOnAuth(ctx, func() error {
|
||||
resp, err := c.rpc.Prune(ctx, &sgardpb.PruneRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune: %w", err)
|
||||
}
|
||||
result = int(resp.BlobsRemoved)
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func writeAndVerify(g *garden.Garden, expectedHash string, data []byte) error {
|
||||
|
||||
Reference in New Issue
Block a user