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:
@@ -4,15 +4,18 @@ import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/kisom/sgard/sgardpb"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
var testJWTKey = []byte("test-jwt-secret-key-32-bytes!!")
|
||||
|
||||
func generateTestKey(t *testing.T) (ssh.Signer, ssh.PublicKey) {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
@@ -26,94 +29,118 @@ func generateTestKey(t *testing.T) (ssh.Signer, ssh.PublicKey) {
|
||||
return signer, signer.PublicKey()
|
||||
}
|
||||
|
||||
func signedContext(t *testing.T, signer ssh.Signer) context.Context {
|
||||
t.Helper()
|
||||
func TestAuthenticateAndVerifyToken(t *testing.T) {
|
||||
signer, pubkey := generateTestKey(t)
|
||||
auth := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}, testJWTKey)
|
||||
|
||||
nonce, err := GenerateNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("generating nonce: %v", err)
|
||||
}
|
||||
// Generate a signed challenge.
|
||||
nonce, _ := GenerateNonce()
|
||||
tsUnix := time.Now().Unix()
|
||||
payload := buildPayload(nonce, tsUnix)
|
||||
|
||||
sig, err := signer.Sign(rand.Reader, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("signing: %v", err)
|
||||
}
|
||||
|
||||
pubkeyStr := string(ssh.MarshalAuthorizedKey(signer.PublicKey()))
|
||||
pubkeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
|
||||
|
||||
md := metadata.New(map[string]string{
|
||||
metaNonce: base64.StdEncoding.EncodeToString(nonce),
|
||||
metaTimestamp: strconv.FormatInt(tsUnix, 10),
|
||||
metaSignature: base64.StdEncoding.EncodeToString(ssh.Marshal(sig)),
|
||||
metaPubkey: pubkeyStr,
|
||||
// Call Authenticate.
|
||||
resp, err := auth.Authenticate(context.Background(), &sgardpb.AuthenticateRequest{
|
||||
Nonce: nonce,
|
||||
Timestamp: tsUnix,
|
||||
Signature: ssh.Marshal(sig),
|
||||
PublicKey: pubkeyStr,
|
||||
})
|
||||
return metadata.NewIncomingContext(context.Background(), md)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if resp.Token == "" {
|
||||
t.Fatal("expected non-empty token")
|
||||
}
|
||||
|
||||
func TestAuthVerifyValid(t *testing.T) {
|
||||
signer, pubkey := generateTestKey(t)
|
||||
interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey})
|
||||
|
||||
ctx := signedContext(t, signer)
|
||||
if err := interceptor.verify(ctx); err != nil {
|
||||
t.Fatalf("verify should succeed: %v", err)
|
||||
// Use the token in metadata.
|
||||
md := metadata.New(map[string]string{metaToken: resp.Token})
|
||||
ctx := metadata.NewIncomingContext(context.Background(), md)
|
||||
if err := auth.verifyToken(ctx); err != nil {
|
||||
t.Fatalf("verifyToken should accept valid token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectUnauthenticated(t *testing.T) {
|
||||
func TestRejectMissingToken(t *testing.T) {
|
||||
_, pubkey := generateTestKey(t)
|
||||
interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey})
|
||||
auth := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}, testJWTKey)
|
||||
|
||||
// No metadata at all.
|
||||
ctx := context.Background()
|
||||
if err := interceptor.verify(ctx); err == nil {
|
||||
t.Fatal("verify should reject missing metadata")
|
||||
if err := auth.verifyToken(context.Background()); err == nil {
|
||||
t.Fatal("should reject missing metadata")
|
||||
}
|
||||
|
||||
// Empty metadata.
|
||||
md := metadata.New(nil)
|
||||
ctx := metadata.NewIncomingContext(context.Background(), md)
|
||||
if err := auth.verifyToken(ctx); err == nil {
|
||||
t.Fatal("should reject missing token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectUnauthorizedKey(t *testing.T) {
|
||||
func TestRejectUnauthorizedKey(t *testing.T) {
|
||||
signer1, _ := generateTestKey(t)
|
||||
_, pubkey2 := generateTestKey(t)
|
||||
|
||||
// Interceptor knows key2 but request is signed by key1.
|
||||
interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey2})
|
||||
// Auth only knows pubkey2, but we authenticate with signer1.
|
||||
auth := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey2}, testJWTKey)
|
||||
|
||||
ctx := signedContext(t, signer1)
|
||||
if err := interceptor.verify(ctx); err == nil {
|
||||
t.Fatal("verify should reject unauthorized key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectExpiredTimestamp(t *testing.T) {
|
||||
signer, pubkey := generateTestKey(t)
|
||||
interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey})
|
||||
|
||||
nonce, err := GenerateNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("generating nonce: %v", err)
|
||||
}
|
||||
// Timestamp 10 minutes ago — outside the 5-minute window.
|
||||
tsUnix := time.Now().Add(-10 * time.Minute).Unix()
|
||||
nonce, _ := GenerateNonce()
|
||||
tsUnix := time.Now().Unix()
|
||||
payload := buildPayload(nonce, tsUnix)
|
||||
sig, _ := signer1.Sign(rand.Reader, payload)
|
||||
pubkeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer1.PublicKey())))
|
||||
|
||||
sig, err := signer.Sign(rand.Reader, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("signing: %v", err)
|
||||
}
|
||||
|
||||
pubkeyStr := string(ssh.MarshalAuthorizedKey(signer.PublicKey()))
|
||||
|
||||
md := metadata.New(map[string]string{
|
||||
metaNonce: base64.StdEncoding.EncodeToString(nonce),
|
||||
metaTimestamp: strconv.FormatInt(tsUnix, 10),
|
||||
metaSignature: base64.StdEncoding.EncodeToString(ssh.Marshal(sig)),
|
||||
metaPubkey: pubkeyStr,
|
||||
_, err := auth.Authenticate(context.Background(), &sgardpb.AuthenticateRequest{
|
||||
Nonce: nonce,
|
||||
Timestamp: tsUnix,
|
||||
Signature: ssh.Marshal(sig),
|
||||
PublicKey: pubkeyStr,
|
||||
})
|
||||
ctx := metadata.NewIncomingContext(context.Background(), md)
|
||||
|
||||
if err := interceptor.verify(ctx); err == nil {
|
||||
t.Fatal("verify should reject expired timestamp")
|
||||
if err == nil {
|
||||
t.Fatal("should reject unauthorized key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredTokenReturnsChallenge(t *testing.T) {
|
||||
signer, pubkey := generateTestKey(t)
|
||||
auth := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}, testJWTKey)
|
||||
|
||||
// Issue a token, then manually create an expired one.
|
||||
fp := ssh.FingerprintSHA256(signer.PublicKey())
|
||||
expiredToken, err := auth.issueExpiredToken(fp)
|
||||
if err != nil {
|
||||
t.Fatalf("issuing expired token: %v", err)
|
||||
}
|
||||
|
||||
md := metadata.New(map[string]string{metaToken: expiredToken})
|
||||
ctx := metadata.NewIncomingContext(context.Background(), md)
|
||||
err = auth.verifyToken(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("should reject expired token")
|
||||
}
|
||||
|
||||
// The error should contain a ReauthChallenge in its details.
|
||||
// We can't easily extract it here without the client helper,
|
||||
// but verify the error message indicates expiry.
|
||||
if !strings.Contains(err.Error(), "expired") {
|
||||
t.Errorf("error should mention expiry, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// issueExpiredToken is a test helper that creates an already-expired JWT.
|
||||
func (a *AuthInterceptor) issueExpiredToken(fingerprint string) (string, error) {
|
||||
past := time.Now().Add(-time.Hour)
|
||||
claims := &jwt.RegisteredClaims{
|
||||
Subject: fingerprint,
|
||||
IssuedAt: jwt.NewNumericDate(past.Add(-24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(past),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(a.jwtKey)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user