Repository: google/s2a-go
Pull request #155: HTTP Client TLS configs should send identity to the S2A server
Author: login: gscert
Created: 2025-04-08T04:00:00Z | Merged: 2025-04-09T20:35:04Z



Diff:
diff --git a/internal/v2/s2av2.go b/internal/v2/s2av2.go
index 0cc7854..c59e1c8 100644
--- a/internal/v2/s2av2.go
+++ b/internal/v2/s2av2.go
@@ -307,14 +307,15 @@ func NewClientTLSConfig(
 	verificationMode s2av2pb.ValidatePeerCertificateChainReq_VerificationMode,
 	serverName string,
 	serverAuthorizationPolicy []byte,
-	getStream stream.GetS2AStream) (*tls.Config, error) {
+	getStream stream.GetS2AStream,
+	localIdentity *commonpb.Identity) (*tls.Config, error) {
 	s2AStream, err := createStream(ctx, s2av2Address, transportCreds, getStream)
 	if err != nil {
 		grpclog.Infof("Failed to connect to S2Av2: %v", err)
 		return nil, err
 	}
 
-	return tlsconfigstore.GetTLSConfigurationForClient(removeServerNamePort(serverName), s2AStream, tokenManager, nil, verificationMode, serverAuthorizationPolicy)
+	return tlsconfigstore.GetTLSConfigurationForClient(removeServerNamePort(serverName), s2AStream, tokenManager, localIdentity, verificationMode, serverAuthorizationPolicy)
 }
 
 // OverrideServerName sets the ServerName in the s2av2TransportCreds protocol
diff --git a/internal/v2/s2av2_e2e_test.go b/internal/v2/s2av2_e2e_test.go
index 294dc3b..3038bf9 100644
--- a/internal/v2/s2av2_e2e_test.go
+++ b/internal/v2/s2av2_e2e_test.go
@@ -414,16 +414,45 @@ func TestGRPCRetryAndFallbackEndToEndUsingFakeS2AOverTCP(t *testing.T) {
 	}
 }
 
+func TestNewClientTlsConfigWithNilLocalIdentityNoError(t *testing.T) {
+	t.Setenv(accessTokenEnvVariable, "TestNewClientTlsConfig_token")
+	s2AAddr := startFakeS2A(t, "TestNewClientTlsConfig_token")
+	accessTokenManager, err := tokenmanager.NewSingleTokenAccessTokenManager()
+	if err != nil {
+		t.Errorf("tokenmanager.NewSingleTokenAccessTokenManager() failed: %v", err)
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), defaultE2ETimeout)
+	defer cancel()
+	config, err := NewClientTLSConfig(ctx, s2AAddr, nil, accessTokenManager, s2av2pb.ValidatePeerCertificateChainReq_CONNECT_TO_GOOGLE, "test_server_name", nil, nil, nil)
+	if err != nil {
+		t.Errorf("NewClientTLSConfig() failed: %v", err)
+	}
+
+	cert, err := tls.X509KeyPair(clientCertpem, clientKeypem)
+	if err != nil {
+		t.Fatalf("tls.X509KeyPair failed: %v", err)
+	}
+	if got, want := config.Certificates[0].Certificate[0], cert.Certificate[0]; !bytes.Equal(got, want) {
+		t.Errorf("tls.Config has unexpected certificate: got: %v, want: %v", got, want)
+	}
+}
+
 func TestNewClientTlsConfigWithTokenManager(t *testing.T) {
 	os.Setenv(accessTokenEnvVariable, "TestNewClientTlsConfig_token")
 	s2AAddr := startFakeS2A(t, "TestNewClientTlsConfig_token")
+	identity := &commonpb.Identity{
+		Attributes: map[string]string{},
+		IdentityOneof: &commonpb.Identity_Hostname{
+			Hostname: "some_hostname",
+		},
+	}
 	accessTokenManager, err := tokenmanager.NewSingleTokenAccessTokenManager()
 	if err != nil {
 		t.Errorf("tokenmanager.NewSingleTokenAccessTokenManager() failed: %v", err)
 	}
 	ctx, cancel := context.WithTimeout(context.Background(), defaultE2ETimeout)
 	defer cancel()
-	config, err := NewClientTLSConfig(ctx, s2AAddr, nil, accessTokenManager, s2av2pb.ValidatePeerCertificateChainReq_CONNECT_TO_GOOGLE, "test_server_name", nil, nil)
+	config, err := NewClientTLSConfig(ctx, s2AAddr, nil, accessTokenManager, s2av2pb.ValidatePeerCertificateChainReq_CONNECT_TO_GOOGLE, "test_server_name", nil, nil, identity)
 	if err != nil {
 		t.Errorf("NewClientTLSConfig() failed: %v", err)
 	}
@@ -440,10 +469,16 @@ func TestNewClientTlsConfigWithTokenManager(t *testing.T) {
 func TestNewClientTlsConfigWithoutTokenManager(t *testing.T) {
 	os.Unsetenv(accessTokenEnvVariable)
 	s2AAddr := startFakeS2A(t, "ignored-value")
+	identity := &commonpb.Identity{
+		Attributes: map[string]string{},
+		IdentityOneof: &commonpb.Identity_Hostname{
+			Hostname: "some_hostname",
+		},
+	}
 	var tokenManager tokenmanager.AccessTokenManager
 	ctx, cancel := context.WithTimeout(context.Background(), defaultE2ETimeout)
 	defer cancel()
-	config, err := NewClientTLSConfig(ctx, s2AAddr, nil, tokenManager, s2av2pb.ValidatePeerCertificateChainReq_CONNECT_TO_GOOGLE, "test_server_name", nil, nil)
+	config, err := NewClientTLSConfig(ctx, s2AAddr, nil, tokenManager, s2av2pb.ValidatePeerCertificateChainReq_CONNECT_TO_GOOGLE, "test_server_name", nil, nil, identity)
 	if err != nil {
 		t.Errorf("NewClientTLSConfig() failed: %v", err)
 	}
@@ -460,6 +495,12 @@ func TestNewClientTlsConfigWithoutTokenManager(t *testing.T) {
 func TestNewClientTlsConfigWithCustomS2AStream(t *testing.T) {
 	os.Unsetenv(accessTokenEnvVariable)
 	s2aAddr := startFakeS2A(t, "TestNewClientTlsConfig_token")
+	identity := &commonpb.Identity{
+		Attributes: map[string]string{},
+		IdentityOneof: &commonpb.Identity_Hostname{
+			Hostname: "some_hostname",
+		},
+	}
 	var tokenManager tokenmanager.AccessTokenManager
 	ctx, cancel := context.WithTimeout(context.Background(), defaultE2ETimeout)
 	t.Cleanup(func() {
@@ -472,7 +513,7 @@ func TestNewClientTlsConfigWithCustomS2AStream(t *testing.T) {
 		return s2ATestStream{debug: "test S2A stream"}, nil
 	}
 
-	_, err := NewClientTLSConfig(ctx, s2aAddr, nil, tokenManager, s2av2pb.ValidatePeerCertificateChainReq_CONNECT_TO_GOOGLE, "test_server_name", nil, getS2AStream)
+	_, err := NewClientTLSConfig(ctx, s2aAddr, nil, tokenManager, s2av2pb.ValidatePeerCertificateChainReq_CONNECT_TO_GOOGLE, "test_server_name", nil, getS2AStream, identity)
 	if err != nil {
 		t.Errorf("NewClientTLSConfig() failed: %v", err)
 	}
diff --git a/s2a.go b/s2a.go
index c52fccd..1796b30 100644
--- a/s2a.go
+++ b/s2a.go
@@ -33,7 +33,7 @@ import (
 	"github.com/google/s2a-go/internal/handshaker"
 	"github.com/google/s2a-go/internal/handshaker/service"
 	"github.com/google/s2a-go/internal/tokenmanager"
-	"github.com/google/s2a-go/internal/v2"
+	v2 "github.com/google/s2a-go/internal/v2"
 	"github.com/google/s2a-go/retry"
 	"github.com/google/s2a-go/stream"
 	"google.golang.org/grpc/credentials"
@@ -320,6 +320,10 @@ func NewTLSClientConfigFactory(opts *ClientOptions) (TLSClientConfigFactory, err
 	if opts.EnableLegacyMode {
 		return nil, fmt.Errorf("NewTLSClientConfigFactory only supports S2Av2")
 	}
+	localIdentity, err := toV2ProtoIdentity(opts.LocalIdentity)
+	if err != nil {
+		return nil, err
+	}
 	tokenManager, err := tokenmanager.NewSingleTokenAccessTokenManager()
 	if err != nil {
 		// The only possible error is: access token not set in the environment,
@@ -332,6 +336,7 @@ func NewTLSClientConfigFactory(opts *ClientOptions) (TLSClientConfigFactory, err
 			verificationMode:          getVerificationMode(opts.VerificationMode),
 			serverAuthorizationPolicy: opts.serverAuthorizationPolicy,
 			getStream:                 opts.getS2AStream,
+			localIdentity:             localIdentity,
 		}, nil
 	}
 	return &s2aTLSClientConfigFactory{
@@ -341,6 +346,7 @@ func NewTLSClientConfigFactory(opts *ClientOptions) (TLSClientConfigFactory, err
 		verificationMode:          getVerificationMode(opts.VerificationMode),
 		serverAuthorizationPolicy: opts.serverAuthorizationPolicy,
 		getStream:                 opts.getS2AStream,
+		localIdentity:             localIdentity,
 	}, nil
 }
 
@@ -351,6 +357,8 @@ type s2aTLSClientConfigFactory struct {
 	verificationMode          s2av2pb.ValidatePeerCertificateChainReq_VerificationMode
 	serverAuthorizationPolicy []byte
 	getStream                 stream.GetS2AStream
+	// localIdentity should only be used by the client.
+	localIdentity *commonpb.Identity
 }
 
 func (f *s2aTLSClientConfigFactory) Build(
@@ -359,7 +367,7 @@ func (f *s2aTLSClientConfigFactory) Build(
 	if opts != nil && opts.ServerName != "" {
 		serverName = opts.ServerName
 	}
-	return v2.NewClientTLSConfig(ctx, f.s2av2Address, f.transportCreds, f.tokenManager, f.verificationMode, serverName, f.serverAuthorizationPolicy, f.getStream)
+	return v2.NewClientTLSConfig(ctx, f.s2av2Address, f.transportCreds, f.tokenManager, f.verificationMode, serverName, f.serverAuthorizationPolicy, f.getStream, f.localIdentity)
 }
 
 func getVerificationMode(verificationMode VerificationModeType) s2av2pb.ValidatePeerCertificateChainReq_VerificationMode {
diff --git a/s2a_e2e_test.go b/s2a_e2e_test.go
index 234a0d7..3802599 100644
--- a/s2a_e2e_test.go
+++ b/s2a_e2e_test.go
@@ -616,6 +616,35 @@ func TestV2EndToEndUsingFakeS2AOnUDS(t *testing.T) {
 	runClient(ctx, t, clientS2AAddress, nil, serverAddress, false, nil)
 }
 
+func TestNewTLSClientConfigFactoryWithNilLocalIdentityNoError(t *testing.T) {
+	t.Setenv(accessTokenEnvVariable, "TestNewTLSClientConfigFactory_token")
+	s2AAddr := startFakeS2A(t, false, "TestNewTLSClientConfigFactory_token", nil)
+	ctx, cancel := context.WithTimeout(context.Background(), defaultE2ETestTimeout)
+	defer cancel()
+
+	factory, err := NewTLSClientConfigFactory(&ClientOptions{
+		S2AAddress:    s2AAddr,
+		LocalIdentity: nil,
+	})
+	if err != nil {
+		t.Errorf("NewTLSClientConfigFactory() failed: %v", err)
+	}
+
+	config, err := factory.Build(ctx, nil)
+	if err != nil {
+		t.Errorf("Build tls config failed: %v", err)
+	}
+
+	cert, err := tls.X509KeyPair(clientCertpem, clientKeypem)
+	if err != nil {
+		t.Fatalf("tls.X509KeyPair failed: %v", err)
+	}
+
+	if got, want := config.Certificates[0].Certificate[0], cert.Certificate[0]; !bytes.Equal(got, want) {
+		t.Errorf("tls.Config has unexpected certificate: got: %v, want: %v", got, want)
+	}
+}
+
 func TestNewTLSClientConfigFactoryWithTokenManager(t *testing.T) {
 	os.Setenv(accessTokenEnvVariable, "TestNewTLSClientConfigFactory_token")
 	s2AAddr := startFakeS2A(t, false, "TestNewTLSClientConfigFactory_token", nil)
@@ -623,7 +652,8 @@ func TestNewTLSClientConfigFactoryWithTokenManager(t *testing.T) {
 	defer cancel()
 
 	factory, err := NewTLSClientConfigFactory(&ClientOptions{
-		S2AAddress: s2AAddr,
+		S2AAddress:    s2AAddr,
+		LocalIdentity: &UnspecifiedID{},
 	})
 	if err != nil {
 		t.Errorf("NewTLSClientConfigFactory() failed: %v", err)
@@ -651,7 +681,8 @@ func TestNewTLSClientConfigFactoryWithoutTokenManager(t *testing.T) {
 	defer cancel()
 
 	factory, err := NewTLSClientConfigFactory(&ClientOptions{
-		S2AAddress: s2AAddr,
+		S2AAddress:    s2AAddr,
+		LocalIdentity: &UnspecifiedID{},
 	})
 	if err != nil {
 		t.Errorf("NewTLSClientConfigFactory() failed: %v", err)
diff --git a/s2a_options_test.go b/s2a_options_test.go
index c6e7547..370999e 100644
--- a/s2a_options_test.go
+++ b/s2a_options_test.go
@@ -23,6 +23,7 @@ import (
 
 	"github.com/google/go-cmp/cmp"
 	s2apb "github.com/google/s2a-go/internal/proto/common_go_proto"
+	s2av2pb "github.com/google/s2a-go/internal/proto/v2/common_go_proto"
 	"google.golang.org/protobuf/testing/protocmp"
 )
 
@@ -65,3 +66,63 @@ func TestToProtoIdentity(t *testing.T) {
 		})
 	}
 }
+
+func TestToV2ProtoIdentity(t *testing.T) {
+	for _, tc := range []struct {
+		identity    Identity
+		outIdentity *s2av2pb.Identity
+	}{
+		{
+			identity: NewSpiffeID("test_spiffe_id"),
+			outIdentity: &s2av2pb.Identity{
+				IdentityOneof: &s2av2pb.Identity_SpiffeId{SpiffeId: "test_spiffe_id"},
+			},
+		},
+		{
+			identity: NewHostname("test_hostname"),
+			outIdentity: &s2av2pb.Identity{
+				IdentityOneof: &s2av2pb.Identity_Hostname{Hostname: "test_hostname"},
+			},
+		},
+		{
+			identity: NewUID("test_uid"),
+			outIdentity: &s2av2pb.Identity{
+				IdentityOneof: &s2av2pb.Identity_Uid{Uid: "test_uid"},
+			},
+		},
+		{
+			identity: &UnspecifiedID{
+				Attr: map[string]string{"key": "value"},
+			},
+			outIdentity: &s2av2pb.Identity{
+				Attributes: map[string]string{"key": "value"},
+			},
+		},
+		{
+			identity:    nil,
+			outIdentity: nil,
+		},
+	} {
+		t.Run(tc.outIdentity.String(), func(t *testing.T) {
+			protoSpiffeID, err := toV2ProtoIdentity(tc.identity)
+			if err != nil {
+				t.Errorf("toV2ProtoIdentity(%v) failed: %v", tc.identity, err)
+			}
+			if got, want := protoSpiffeID, tc.outIdentity; !cmp.Equal(got, want, protocmp.Transform()) {
+				t.Errorf("toV2ProtoIdentity(%v) = %v, want %v", tc.outIdentity, got, want)
+			}
+		})
+	}
+}
+
+// Implements the Identity interface and is used to get an error from toV2ProtoIdentity.
+type UnsupportedIdentity struct{}
+
+func (u *UnsupportedIdentity) Name() string                  { return "" }
+func (u *UnsupportedIdentity) Attributes() map[string]string { return map[string]string{} }
+
+func TestToV2ProtoIdentityError(t *testing.T) {
+	if _, err := toV2ProtoIdentity(&UnsupportedIdentity{}); err == nil {
+		t.Errorf("toV2ProtoIdentity(&UnsupportedIdentity{}) err = nil, want err != nil")
+	}
+}


Reviews:
user: login: xmenxk
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T22:13:28Z
user: login: gscert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T00:07:57Z
user: login: xmenxk
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-09T17:00:12Z

Review comments:
diff_hunk: @@ -320,6 +320,10 @@ func NewTLSClientConfigFactory(opts *ClientOptions) (TLSClientConfigFactory, err
 	if opts.EnableLegacyMode {
 		return nil, fmt.Errorf("NewTLSClientConfigFactory only supports S2Av2")
 	}
+	localIdentity, err := toV2ProtoIdentity(opts.LocalIdentity)
+	if err != nil {
path: s2a.go
user: login: xmenxk
body: so if a LocalIdentity is not specified, then we get an error?
created_at: 2025-04-08T22:13:07Z
author_association: COLLABORATOR
diff_hunk: @@ -320,6 +320,10 @@ func NewTLSClientConfigFactory(opts *ClientOptions) (TLSClientConfigFactory, err
 	if opts.EnableLegacyMode {
 		return nil, fmt.Errorf("NewTLSClientConfigFactory only supports S2Av2")
 	}
+	localIdentity, err := toV2ProtoIdentity(opts.LocalIdentity)
+	if err != nil {
path: s2a.go
user: login: gscert
body: I believe toV2ProtoIdentity only returns an error if an unsupported identity is provided. I added some tests to make sure.
created_at: 2025-04-09T00:07:57Z
author_association: MEMBER

PR comments:
user: login: gscert
created_at: 2025-04-08T04:00:58Z
body: Hey @zeromath, can you PTAL?
author_association: MEMBER
user: login: zeromath
created_at: 2025-04-08T21:45:54Z
body: Shall we rename this to saying that this change is for HTTP client?

Other than this, everything else looks good to me.
author_association: COLLABORATOR