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:
2026-03-24 00:52:16 -07:00
parent b7b1b27064
commit edef642025
18 changed files with 890 additions and 283 deletions

View File

@@ -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 {