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

@@ -210,8 +210,9 @@ func TestPrune(t *testing.T) {
}
}
func TestAuthIntegration(t *testing.T) {
// Generate an ed25519 key pair.
var testJWTKey = []byte("test-jwt-secret-key-32-bytes!!")
func TestTokenAuthIntegration(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
@@ -227,19 +228,18 @@ func TestAuthIntegration(t *testing.T) {
t.Fatalf("init server garden: %v", err)
}
// Set up server with auth interceptor.
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()})
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()}, testJWTKey)
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer(
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
grpc.StreamInterceptor(auth.StreamInterceptor()),
)
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
sgardpb.RegisterGardenSyncServer(srv, server.NewWithAuth(serverGarden, auth))
t.Cleanup(func() { srv.Stop() })
go func() { _ = srv.Serve(lis) }()
// Client with SSH credentials.
creds := NewSSHCredentials(signer)
// Client with token auth + auto-renewal.
creds := NewTokenCredentials("")
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
@@ -252,16 +252,22 @@ func TestAuthIntegration(t *testing.T) {
}
t.Cleanup(func() { _ = conn.Close() })
c := New(conn)
c := NewWithAuth(conn, creds, signer)
// Authenticated request should succeed.
_, err = c.Pull(context.Background(), serverGarden)
// No token yet — EnsureAuth should authenticate via SSH.
ctx := context.Background()
if err := c.EnsureAuth(ctx); err != nil {
t.Fatalf("EnsureAuth: %v", err)
}
// Now requests should work.
_, err = c.Pull(ctx, serverGarden)
if err != nil {
t.Fatalf("authenticated Pull should succeed: %v", err)
}
}
func TestAuthIntegrationRejectsUnauthenticated(t *testing.T) {
func TestAuthRejectsUnauthenticated(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
@@ -277,17 +283,17 @@ func TestAuthIntegrationRejectsUnauthenticated(t *testing.T) {
t.Fatalf("init server garden: %v", err)
}
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()})
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()}, testJWTKey)
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer(
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
grpc.StreamInterceptor(auth.StreamInterceptor()),
)
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
sgardpb.RegisterGardenSyncServer(srv, server.NewWithAuth(serverGarden, auth))
t.Cleanup(func() { srv.Stop() })
go func() { _ = srv.Serve(lis) }()
// Client WITHOUT credentials.
// Client WITHOUT credentials — no token, no signer.
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()