diff --git a/lib/auth/auth.go b/lib/auth/auth.go
index ced55fa3c004f..c9d6f55e4b3cd 100644
--- a/lib/auth/auth.go
+++ b/lib/auth/auth.go
@@ -113,7 +113,6 @@ import (
"github.com/gravitational/teleport/lib/devicetrust/assertserver"
dtconfig "github.com/gravitational/teleport/lib/devicetrust/config"
"github.com/gravitational/teleport/lib/events"
- "github.com/gravitational/teleport/lib/gcp"
"github.com/gravitational/teleport/lib/integrations/awsra/createsession"
"github.com/gravitational/teleport/lib/inventory"
iterstream "github.com/gravitational/teleport/lib/itertools/stream"
@@ -121,6 +120,7 @@ import (
joinboundkeypair "github.com/gravitational/teleport/lib/join/boundkeypair"
"github.com/gravitational/teleport/lib/join/ec2join"
"github.com/gravitational/teleport/lib/join/env0"
+ "github.com/gravitational/teleport/lib/join/gcp"
"github.com/gravitational/teleport/lib/join/githubactions"
"github.com/gravitational/teleport/lib/join/gitlab"
"github.com/gravitational/teleport/lib/join/tpmjoin"
@@ -1314,7 +1314,7 @@ type Server struct {
// gcpIDTokenValidator allows ID tokens from GCP to be validated by the auth
// server. It can be overridden for the purpose of tests.
- gcpIDTokenValidator gcpIDTokenValidator
+ gcpIDTokenValidator gcp.Validator
// terraformIDTokenValidator allows JWTs from Terraform Cloud to be
// validated by the auth server using a known JWKS. It can be overridden for
diff --git a/lib/auth/export_test.go b/lib/auth/export_test.go
index 5d47122d1a739..1689067d4ed03 100644
--- a/lib/auth/export_test.go
+++ b/lib/auth/export_test.go
@@ -208,10 +208,6 @@ func (a *Server) SetCircleCITokenValidate(validator func(ctx context.Context, or
a.circleCITokenValidate = validator
}
-func (a *Server) SetGCPIDTokenValidator(validator gcpIDTokenValidator) {
- a.gcpIDTokenValidator = validator
-}
-
func (a *Server) SetK8sTokenReviewValidator(validator k8sTokenReviewValidator) {
a.k8sTokenReviewValidator = validator
}
@@ -360,10 +356,6 @@ func ValidateGithubAuthCallbackHelper(ctx context.Context, m GitHubManager, diag
return validateGithubAuthCallbackHelper(ctx, m, diagCtx, q, emitter, logger)
}
-func IsGCPZoneInLocation(rawLocation, rawZone string) bool {
- return isGCPZoneInLocation(rawLocation, rawZone)
-}
-
func FormatHeaderFromMap(m map[string]string) http.Header {
return formatHeaderFromMap(m)
}
diff --git a/lib/auth/join/join.go b/lib/auth/join/join.go
index 07aa0696d9299..2179922e86d4e 100644
--- a/lib/auth/join/join.go
+++ b/lib/auth/join/join.go
@@ -352,9 +352,11 @@ func Register(ctx context.Context, params RegisterParams) (result *RegisterResul
return nil, trace.Wrap(err)
}
case types.JoinMethodGCP:
- params.IDToken, err = gcp.GetIDToken(ctx)
- if err != nil {
- return nil, trace.Wrap(err)
+ if params.IDToken == "" {
+ params.IDToken, err = gcp.GetIDToken(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
}
case types.JoinMethodSpacelift:
params.IDToken, err = spacelift.NewIDTokenSource(os.Getenv).GetIDToken()
diff --git a/lib/auth/join_gcp.go b/lib/auth/join_gcp.go
index 46fcd8a0638ab..089069cdb04f0 100644
--- a/lib/auth/join_gcp.go
+++ b/lib/auth/join_gcp.go
@@ -20,17 +20,22 @@ package auth
import (
"context"
- "slices"
- "strings"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/types"
- "github.com/gravitational/teleport/lib/gcp"
+ "github.com/gravitational/teleport/lib/join/gcp"
)
-type gcpIDTokenValidator interface {
- Validate(ctx context.Context, token string) (*gcp.IDTokenClaims, error)
+// GetGCPIDTokenValidator returns the server's configured GCP ID token
+// validator.
+func (a *Server) GetGCPIDTokenValidator() gcp.Validator {
+ return a.gcpIDTokenValidator
+}
+
+// SetGCPIDTokenValidator sets a new GCP ID token validator, used in tests.
+func (a *Server) SetGCPIDTokenValidator(validator gcp.Validator) {
+ a.gcpIDTokenValidator = validator
}
func (a *Server) checkGCPJoinRequest(
@@ -38,112 +43,13 @@ func (a *Server) checkGCPJoinRequest(
req *types.RegisterUsingTokenRequest,
pt types.ProvisionToken,
) (*gcp.IDTokenClaims, error) {
- if req.IDToken == "" {
- return nil, trace.BadParameter("IDToken not provided for GCP join request")
- }
- token, ok := pt.(*types.ProvisionTokenV2)
- if !ok {
- return nil, trace.BadParameter("gcp join method only supports ProvisionTokenV2, '%T' was provided", pt)
- }
-
- claims, err := a.gcpIDTokenValidator.Validate(ctx, req.IDToken)
- if err != nil {
- a.logger.WarnContext(ctx, "Unable to validate GCP IDToken",
- "error", err,
- "claims", claims,
- "token", pt.GetName(),
- )
- return nil, trace.Wrap(err)
- }
-
- a.logger.InfoContext(ctx, "GCP VM trying to join cluster",
- "claims", claims,
- "token", pt.GetName(),
- )
-
- if err := checkGCPAllowRules(token, claims); err != nil {
- return nil, trace.Wrap(err)
- }
-
- return claims, nil
-}
-
-func checkGCPAllowRules(token *types.ProvisionTokenV2, claims *gcp.IDTokenClaims) error {
- compute := claims.Google.ComputeEngine
- // unmatchedLocation is true if the location restriction is set and the "google.compute_engine.zone"
- // claim is not present in the IDToken. This happens when the joining node is not a GCE VM.
- unmatchedLocation := false
- // If a single rule passes, accept the IDToken.
- for _, rule := range token.Spec.GCP.Allow {
- if !slices.Contains(rule.ProjectIDs, compute.ProjectID) {
- continue
- }
-
- if len(rule.ServiceAccounts) > 0 && !slices.Contains(rule.ServiceAccounts, claims.Email) {
- continue
- }
-
- if len(rule.Locations) > 0 && !slices.ContainsFunc(rule.Locations, func(location string) bool {
- return isGCPZoneInLocation(location, compute.Zone)
- }) {
- unmatchedLocation = true
- continue
- }
-
- // All provided rules met.
- return nil
- }
-
- // If the location restriction is set and the "google.compute_engine.zone" claim is not present in the IDToken,
- // return a more specific error message.
- if unmatchedLocation && compute.Zone == "" {
- return trace.CompareFailed("id token %q claim is empty and didn't match the %q. "+
- "Services running outside of GCE VM instances are incompatible with %q restriction.", "google.compute_engine.zone", "locations", "location")
- }
- return trace.AccessDenied("id token claims did not match any allow rules")
-}
-
-type gcpLocation struct {
- globalLocation string
- region string
- zone string
-}
-
-func parseGCPLocation(location string) (*gcpLocation, error) {
- parts := strings.Split(location, "-")
- if len(parts) < 2 || len(parts) > 3 {
- return nil, trace.BadParameter("location %q is not a valid GCP region or zone", location)
- }
- globalLocation, region := parts[0], parts[1]
- var zone string
- if len(parts) == 3 {
- zone = parts[2]
- }
- return &gcpLocation{
- globalLocation: globalLocation,
- region: region,
- zone: zone,
- }, nil
-}
-
-// isGCPZoneInLocation checks if a zone belongs to a location, which can be
-// either a zone or region.
-func isGCPZoneInLocation(rawLocation, rawZone string) bool {
- location, err := parseGCPLocation(rawLocation)
- if err != nil {
- return false
- }
- zone, err := parseGCPLocation(rawZone)
- if err != nil {
- return false
- }
- // Make sure zone is, in fact, a zone.
- if zone.zone == "" {
- return false
- }
-
- if location.globalLocation != zone.globalLocation || location.region != zone.region {
- return false
- }
- return location.zone == "" || location.zone == zone.zone
+ claims, err := gcp.CheckIDToken(ctx, &gcp.CheckIDTokenParams{
+ ProvisionToken: pt,
+ IDToken: []byte(req.IDToken),
+ Validator: a.gcpIDTokenValidator,
+ })
+
+ // Where possible, try to return any extracted claims along with the error
+ // to improve audit logs for failed join attempts.
+ return claims, trace.Wrap(err)
}
diff --git a/lib/gcp/gcp.go b/lib/gcp/gcp.go
deleted file mode 100644
index 7a5ce919fb360..0000000000000
--- a/lib/gcp/gcp.go
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Teleport
- * Copyright (C) 2023 Gravitational, Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-package gcp
-
-import (
- "github.com/zitadel/oidc/v3/pkg/oidc"
-
- workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
-)
-
-// defaultIssuerHost is the issuer for GCP ID tokens.
-const defaultIssuerHost = "accounts.google.com"
-
-// ComputeEngine contains VM-specific token claims.
-type ComputeEngine struct {
- // The ID of the instance's project.
- ProjectID string `json:"project_id"`
- // The instance's zone.
- Zone string `json:"zone"`
- // The instance's ID.
- InstanceID string `json:"instance_id"`
- // The instance's name.
- InstanceName string `json:"instance_name"`
-}
-
-// Google contains Google-specific token claims.
-type Google struct {
- ComputeEngine ComputeEngine `json:"compute_engine"`
-}
-
-// IDTokenClaims is the set of claims in a GCP ID token. GCP documentation for
-// claims can be found at
-// https://cloud.google.com/compute/docs/instances/verifying-instance-identity#payload
-type IDTokenClaims struct {
- oidc.TokenClaims
- // The email of the service account that this token was issued for.
- Email string `json:"email"`
- Google Google `json:"google"`
-}
-
-// JoinAttrs returns the protobuf representation of the attested identity.
-// This is used for auditing and for evaluation of WorkloadIdentity rules and
-// templating.
-func (c *IDTokenClaims) JoinAttrs() *workloadidentityv1pb.JoinAttrsGCP {
- attrs := &workloadidentityv1pb.JoinAttrsGCP{
- ServiceAccount: c.Email,
- }
- if c.Google.ComputeEngine.InstanceName != "" {
- attrs.Gce = &workloadidentityv1pb.JoinAttrsGCPGCE{
- Project: c.Google.ComputeEngine.ProjectID,
- Zone: c.Google.ComputeEngine.Zone,
- Id: c.Google.ComputeEngine.InstanceID,
- Name: c.Google.ComputeEngine.InstanceName,
- }
- }
-
- return attrs
-}
diff --git a/lib/join/gcp/gcp.go b/lib/join/gcp/gcp.go
new file mode 100644
index 0000000000000..c0a2ca9cb2725
--- /dev/null
+++ b/lib/join/gcp/gcp.go
@@ -0,0 +1,224 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package gcp
+
+import (
+ "context"
+ "slices"
+ "strings"
+
+ "github.com/gravitational/trace"
+ "github.com/zitadel/oidc/v3/pkg/oidc"
+
+ "github.com/gravitational/teleport"
+ workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/join/provision"
+ logutils "github.com/gravitational/teleport/lib/utils/log"
+)
+
+var log = logutils.NewPackageLogger(teleport.ComponentKey, "gcp")
+
+// Validator is an interface for GCP token validators, used in tests to provide
+// mock implementations.
+type Validator interface {
+ Validate(ctx context.Context, token string) (*IDTokenClaims, error)
+}
+
+// defaultIssuerHost is the issuer for GCP ID tokens.
+const defaultIssuerHost = "accounts.google.com"
+
+// ComputeEngine contains VM-specific token claims.
+type ComputeEngine struct {
+ // The ID of the instance's project.
+ ProjectID string `json:"project_id"`
+ // The instance's zone.
+ Zone string `json:"zone"`
+ // The instance's ID.
+ InstanceID string `json:"instance_id"`
+ // The instance's name.
+ InstanceName string `json:"instance_name"`
+}
+
+// Google contains Google-specific token claims.
+type Google struct {
+ ComputeEngine ComputeEngine `json:"compute_engine"`
+}
+
+// IDTokenClaims is the set of claims in a GCP ID token. GCP documentation for
+// claims can be found at
+// https://cloud.google.com/compute/docs/instances/verifying-instance-identity#payload
+type IDTokenClaims struct {
+ oidc.TokenClaims
+ // The email of the service account that this token was issued for.
+ Email string `json:"email"`
+ Google Google `json:"google"`
+}
+
+// JoinAttrs returns the protobuf representation of the attested identity.
+// This is used for auditing and for evaluation of WorkloadIdentity rules and
+// templating.
+func (c *IDTokenClaims) JoinAttrs() *workloadidentityv1pb.JoinAttrsGCP {
+ attrs := &workloadidentityv1pb.JoinAttrsGCP{
+ ServiceAccount: c.Email,
+ }
+ if c.Google.ComputeEngine.InstanceName != "" {
+ attrs.Gce = &workloadidentityv1pb.JoinAttrsGCPGCE{
+ Project: c.Google.ComputeEngine.ProjectID,
+ Zone: c.Google.ComputeEngine.Zone,
+ Id: c.Google.ComputeEngine.InstanceID,
+ Name: c.Google.ComputeEngine.InstanceName,
+ }
+ }
+
+ return attrs
+}
+
+// CheckIDTokenParams are parameters used to validate GCP OIDC tokens.
+type CheckIDTokenParams struct {
+ ProvisionToken provision.Token
+ IDToken []byte
+ Validator Validator
+}
+
+func (p *CheckIDTokenParams) validate() error {
+ switch {
+ case p.ProvisionToken == nil:
+ return trace.BadParameter("ProvisionToken is required")
+ case len(p.IDToken) == 0:
+ return trace.BadParameter("IDToken is required")
+ case p.Validator == nil:
+ return trace.BadParameter("Validator is required")
+ }
+ return nil
+}
+
+// CheckIDToken verifies a GCP OIDC token
+func CheckIDToken(
+ ctx context.Context,
+ params *CheckIDTokenParams,
+) (*IDTokenClaims, error) {
+ if err := params.validate(); err != nil {
+ return nil, trace.AccessDenied("%s", err.Error())
+ }
+
+ token, ok := params.ProvisionToken.(*types.ProvisionTokenV2)
+ if !ok {
+ return nil, trace.BadParameter("gcp join method only supports ProvisionTokenV2, '%T' was provided", params.ProvisionToken)
+ }
+
+ claims, err := params.Validator.Validate(ctx, string(params.IDToken))
+ if err != nil {
+ log.WarnContext(ctx, "Unable to validate GCP IDToken",
+ "error", err,
+ "claims", claims,
+ "token", params.ProvisionToken.GetName(),
+ )
+ return nil, trace.Wrap(err)
+ }
+
+ log.InfoContext(ctx, "GCP VM trying to join cluster",
+ "claims", claims,
+ "token", params.ProvisionToken.GetName(),
+ )
+
+ // Note: try to return claims even in case of error to improve logging on
+ // failed auth attempts.
+ return claims, trace.Wrap(checkGCPAllowRules(token, claims))
+}
+
+func checkGCPAllowRules(token *types.ProvisionTokenV2, claims *IDTokenClaims) error {
+ compute := claims.Google.ComputeEngine
+ // unmatchedLocation is true if the location restriction is set and the "google.compute_engine.zone"
+ // claim is not present in the IDToken. This happens when the joining node is not a GCE VM.
+ unmatchedLocation := false
+ // If a single rule passes, accept the IDToken.
+ for _, rule := range token.Spec.GCP.Allow {
+ if !slices.Contains(rule.ProjectIDs, compute.ProjectID) {
+ continue
+ }
+
+ if len(rule.ServiceAccounts) > 0 && !slices.Contains(rule.ServiceAccounts, claims.Email) {
+ continue
+ }
+
+ if len(rule.Locations) > 0 && !slices.ContainsFunc(rule.Locations, func(location string) bool {
+ return isGCPZoneInLocation(location, compute.Zone)
+ }) {
+ unmatchedLocation = true
+ continue
+ }
+
+ // All provided rules met.
+ return nil
+ }
+
+ // If the location restriction is set and the "google.compute_engine.zone" claim is not present in the IDToken,
+ // return a more specific error message.
+ if unmatchedLocation && compute.Zone == "" {
+ return trace.CompareFailed("id token %q claim is empty and didn't match the %q. "+
+ "Services running outside of GCE VM instances are incompatible with %q restriction.", "google.compute_engine.zone", "locations", "location")
+ }
+ return trace.AccessDenied("id token claims did not match any allow rules")
+}
+
+type gcpLocation struct {
+ globalLocation string
+ region string
+ zone string
+}
+
+func parseGCPLocation(location string) (*gcpLocation, error) {
+ parts := strings.Split(location, "-")
+ if len(parts) < 2 || len(parts) > 3 {
+ return nil, trace.BadParameter("location %q is not a valid GCP region or zone", location)
+ }
+ globalLocation, region := parts[0], parts[1]
+ var zone string
+ if len(parts) == 3 {
+ zone = parts[2]
+ }
+ return &gcpLocation{
+ globalLocation: globalLocation,
+ region: region,
+ zone: zone,
+ }, nil
+}
+
+// isGCPZoneInLocation checks if a zone belongs to a location, which can be
+// either a zone or region.
+func isGCPZoneInLocation(rawLocation, rawZone string) bool {
+ location, err := parseGCPLocation(rawLocation)
+ if err != nil {
+ return false
+ }
+ zone, err := parseGCPLocation(rawZone)
+ if err != nil {
+ return false
+ }
+ // Make sure zone is, in fact, a zone.
+ if zone.zone == "" {
+ return false
+ }
+
+ if location.globalLocation != zone.globalLocation || location.region != zone.region {
+ return false
+ }
+ return location.zone == "" || location.zone == zone.zone
+}
diff --git a/lib/join/gcp/gcp_test.go b/lib/join/gcp/gcp_test.go
new file mode 100644
index 0000000000000..3beb9b6eb03c8
--- /dev/null
+++ b/lib/join/gcp/gcp_test.go
@@ -0,0 +1,97 @@
+/*
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package gcp
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsGCPZoneInLocation(t *testing.T) {
+ t.Parallel()
+ passingCases := []struct {
+ name string
+ location string
+ zone string
+ }{
+ {
+ name: "matching zone",
+ location: "us-west1-b",
+ zone: "us-west1-b",
+ },
+ {
+ name: "matching region",
+ location: "us-west1",
+ zone: "us-west1-b",
+ },
+ }
+ for _, tc := range passingCases {
+ t.Run("accept "+tc.name, func(t *testing.T) {
+ require.True(t, isGCPZoneInLocation(tc.location, tc.zone))
+ })
+ }
+
+ failingCases := []struct {
+ name string
+ location string
+ zone string
+ }{
+ {
+ name: "non-matching zone",
+ location: "europe-southwest1-b",
+ zone: "us-west1-b",
+ },
+ {
+ name: "non-matching region",
+ location: "europe-southwest1",
+ zone: "us-west1-b",
+ },
+ {
+ name: "malformed location",
+ location: "us",
+ zone: "us-west1-b",
+ },
+ {
+ name: "similar but non-matching region",
+ location: "europe-west1",
+ zone: "europe-west12-a",
+ },
+ {
+ name: "empty zone",
+ location: "us-west1",
+ zone: "",
+ },
+ {
+ name: "empty location",
+ location: "",
+ zone: "us-west1-b",
+ },
+ {
+ name: "invalid zone",
+ location: "us-west1",
+ zone: "us-west1",
+ },
+ }
+ for _, tc := range failingCases {
+ t.Run("reject "+tc.name, func(t *testing.T) {
+ require.False(t, isGCPZoneInLocation(tc.location, tc.zone))
+ })
+ }
+}
diff --git a/lib/gcp/token_validator.go b/lib/join/gcp/token_validator.go
similarity index 100%
rename from lib/gcp/token_validator.go
rename to lib/join/gcp/token_validator.go
diff --git a/lib/gcp/token_validator_test.go b/lib/join/gcp/token_validator_test.go
similarity index 100%
rename from lib/gcp/token_validator_test.go
rename to lib/join/gcp/token_validator_test.go
diff --git a/lib/auth/join_gcp_test.go b/lib/join/join_gcp_test.go
similarity index 74%
rename from lib/auth/join_gcp_test.go
rename to lib/join/join_gcp_test.go
index 8463b7b1f960f..b3aac86b7f748 100644
--- a/lib/auth/join_gcp_test.go
+++ b/lib/join/join_gcp_test.go
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-package auth_test
+package join_test
import (
"context"
@@ -24,13 +24,15 @@ import (
"time"
"github.com/gravitational/trace"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
- "github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/auth/authtest"
+ "github.com/gravitational/teleport/lib/auth/state"
"github.com/gravitational/teleport/lib/auth/testauthority"
- "github.com/gravitational/teleport/lib/gcp"
+ "github.com/gravitational/teleport/lib/join/gcp"
+ "github.com/gravitational/teleport/lib/join/joinclient"
)
type mockGCPTokenValidator struct {
@@ -45,7 +47,7 @@ func (m *mockGCPTokenValidator) Validate(_ context.Context, token string) (*gcp.
return &claims, nil
}
-func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
+func TestJoinGCP(t *testing.T) {
t.Parallel()
validIDToken := "test.fake.jwt"
@@ -64,14 +66,19 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
},
},
}
- var withTokenValidator auth.ServerOption = func(server *auth.Server) error {
- server.SetGCPIDTokenValidator(idTokenValidator)
- return nil
- }
- ctx := context.Background()
- p, err := newTestPack(ctx, t.TempDir(), withTokenValidator)
+
+ ctx := t.Context()
+
+ authServer, err := authtest.NewTestServer(authtest.ServerConfig{
+ Auth: authtest.AuthServerConfig{
+ Dir: t.TempDir(),
+ },
+ })
require.NoError(t, err)
- auth := p.a
+ t.Cleanup(func() { assert.NoError(t, authServer.Shutdown(t.Context())) })
+ auth := authServer.Auth()
+
+ authServer.Auth().SetGCPIDTokenValidator(idTokenValidator)
// helper for creating RegisterUsingTokenRequest
sshPrivateKey, sshPublicKey, err := testauthority.New().GenerateKeyPair()
@@ -125,7 +132,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
assertError: require.NoError,
},
{
- name: "multiple allow rules",
+ name: "multiple-allow-rules",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGCP,
Roles: []types.SystemRole{types.RoleNode},
@@ -142,7 +149,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
assertError: require.NoError,
},
{
- name: "match region to zone",
+ name: "match-region-to-zone",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGCP,
Roles: []types.SystemRole{types.RoleNode},
@@ -158,7 +165,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
assertError: require.NoError,
},
{
- name: "incorrect project id",
+ name: "incorrect-project-id",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGCP,
Roles: []types.SystemRole{types.RoleNode},
@@ -174,7 +181,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
assertError: allowRulesNotMatched,
},
{
- name: "incorrect location",
+ name: "incorrect-location",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGCP,
Roles: []types.SystemRole{types.RoleNode},
@@ -190,7 +197,7 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
assertError: allowRulesNotMatched,
},
{
- name: "incorrect service account",
+ name: "incorrect-service-account",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGCP,
Roles: []types.SystemRole{types.RoleNode},
@@ -215,80 +222,48 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
require.NoError(t, auth.CreateToken(ctx, token))
tc.request.Token = tc.name
- _, err = auth.RegisterUsingToken(ctx, tc.request)
- tc.assertError(t, err)
- })
- }
-}
+ nopClient, err := authServer.NewClient(authtest.TestNop())
+ require.NoError(t, err)
-func TestIsGCPZoneInLocation(t *testing.T) {
- t.Parallel()
- passingCases := []struct {
- name string
- location string
- zone string
- }{
- {
- name: "matching zone",
- location: "us-west1-b",
- zone: "us-west1-b",
- },
- {
- name: "matching region",
- location: "us-west1",
- zone: "us-west1-b",
- },
- }
- for _, tc := range passingCases {
- t.Run("accept "+tc.name, func(t *testing.T) {
- require.True(t, auth.IsGCPZoneInLocation(tc.location, tc.zone))
- })
- }
+ t.Run("legacy", func(t *testing.T) {
+ _, err = auth.RegisterUsingToken(ctx, tc.request)
+ tc.assertError(t, err)
+ })
- failingCases := []struct {
- name string
- location string
- zone string
- }{
- {
- name: "non-matching zone",
- location: "europe-southwest1-b",
- zone: "us-west1-b",
- },
- {
- name: "non-matching region",
- location: "europe-southwest1",
- zone: "us-west1-b",
- },
- {
- name: "malformed location",
- location: "us",
- zone: "us-west1-b",
- },
- {
- name: "similar but non-matching region",
- location: "europe-west1",
- zone: "europe-west12-a",
- },
- {
- name: "empty zone",
- location: "us-west1",
- zone: "",
- },
- {
- name: "empty location",
- location: "",
- zone: "us-west1-b",
- },
- {
- name: "invalid zone",
- location: "us-west1",
- zone: "us-west1",
- },
- }
- for _, tc := range failingCases {
- t.Run("reject "+tc.name, func(t *testing.T) {
- require.False(t, auth.IsGCPZoneInLocation(tc.location, tc.zone))
+ t.Run("legacy joinclient", func(t *testing.T) {
+ _, err := joinclient.LegacyJoin(t.Context(), joinclient.JoinParams{
+ Token: tc.request.Token,
+ JoinMethod: types.JoinMethodGCP,
+ ID: state.IdentityID{
+ Role: tc.request.Role,
+ NodeName: "testnode",
+ HostUUID: tc.request.HostID,
+ },
+ IDToken: tc.request.IDToken,
+ AuthClient: nopClient,
+ })
+ tc.assertError(t, err)
+ if err != nil {
+ return
+ }
+ })
+
+ t.Run("new joinclient", func(t *testing.T) {
+ _, err := joinclient.Join(t.Context(), joinclient.JoinParams{
+ Token: tc.request.Token,
+ JoinMethod: types.JoinMethodGCP,
+ ID: state.IdentityID{
+ Role: types.RoleInstance, // RoleNode is not allowed
+ NodeName: "testnode",
+ },
+ IDToken: tc.request.IDToken,
+ AuthClient: nopClient,
+ })
+ tc.assertError(t, err)
+ if err != nil {
+ return
+ }
+ })
})
}
}
diff --git a/lib/join/joinclient/join.go b/lib/join/joinclient/join.go
index c2d3e93db62bf..10853e1a5ba67 100644
--- a/lib/join/joinclient/join.go
+++ b/lib/join/joinclient/join.go
@@ -32,6 +32,7 @@ import (
"github.com/gravitational/teleport/api/types"
authjoin "github.com/gravitational/teleport/lib/auth/join"
proxyinsecureclient "github.com/gravitational/teleport/lib/client/proxy/insecure"
+ "github.com/gravitational/teleport/lib/cloud/imds/gcp"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/join/env0"
"github.com/gravitational/teleport/lib/join/githubactions"
@@ -200,6 +201,7 @@ func joinWithClient(ctx context.Context, params JoinParams, client *joinv1.Clien
types.JoinMethodBoundKeypair,
types.JoinMethodEC2,
types.JoinMethodEnv0,
+ types.JoinMethodGCP,
types.JoinMethodGitHub,
types.JoinMethodGitLab,
types.JoinMethodIAM,
@@ -309,6 +311,15 @@ func joinWithMethod(
return oidcJoin(stream, joinParams, clientParams)
case types.JoinMethodOracle:
return oracleJoin(ctx, stream, joinParams, clientParams)
+ case types.JoinMethodGCP:
+ if joinParams.IDToken == "" {
+ joinParams.IDToken, err = gcp.GetIDToken(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ }
+
+ return oidcJoin(stream, joinParams, clientParams)
case types.JoinMethodGitHub:
if joinParams.IDToken == "" {
joinParams.IDToken, err = githubactions.NewIDTokenSource().GetIDToken(ctx)
diff --git a/lib/join/server.go b/lib/join/server.go
index cdd8ea69d3f4a..e05ae266b122f 100644
--- a/lib/join/server.go
+++ b/lib/join/server.go
@@ -46,6 +46,7 @@ import (
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/join/ec2join"
+ "github.com/gravitational/teleport/lib/join/gcp"
"github.com/gravitational/teleport/lib/join/githubactions"
"github.com/gravitational/teleport/lib/join/gitlab"
joinauthz "github.com/gravitational/teleport/lib/join/internal/authz"
@@ -84,6 +85,7 @@ type AuthService interface {
GetHTTPClientForAWSSTS() utils.HTTPDoClient
GetEC2ClientForEC2JoinMethod() ec2join.EC2Client
GetEnv0IDTokenValidator() Env0TokenValidator
+ GetGCPIDTokenValidator() gcp.Validator
GetGHAIDTokenValidator() githubactions.GithubIDTokenValidator
GetGHAIDTokenJWKSValidator() githubactions.GithubIDTokenJWKSValidator
GetGitlabIDTokenValidator() gitlab.Validator
@@ -298,6 +300,8 @@ func (s *Server) handleJoinMethod(
return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateEnv0Token)
case types.JoinMethodOracle:
return s.handleOracleJoin(stream, authCtx, clientInit, token)
+ case types.JoinMethodGCP:
+ return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateGCPToken)
case types.JoinMethodGitHub:
return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateGithubToken)
case types.JoinMethodGitLab:
diff --git a/lib/join/server_gcp.go b/lib/join/server_gcp.go
new file mode 100644
index 0000000000000..3058bec665d05
--- /dev/null
+++ b/lib/join/server_gcp.go
@@ -0,0 +1,55 @@
+/*
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package join
+
+import (
+ "context"
+
+ "github.com/gravitational/trace"
+
+ workloadidentityv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
+ "github.com/gravitational/teleport/lib/join/gcp"
+ "github.com/gravitational/teleport/lib/join/provision"
+)
+
+// validateGCPToken performs validation and allow rule verification against
+// a GCP OIDC token.
+func (a *Server) validateGCPToken(
+ ctx context.Context,
+ pt provision.Token,
+ idToken []byte,
+) (any, *workloadidentityv1.JoinAttrs, error) {
+ claims, err := gcp.CheckIDToken(ctx, &gcp.CheckIDTokenParams{
+ ProvisionToken: pt,
+ IDToken: idToken,
+ Validator: a.cfg.AuthService.GetGCPIDTokenValidator(),
+ })
+
+ // If possible, attach claims and workload ID attrs regardless of the error
+ // return. If the token fails to validate, these claims will ensure audit
+ // events remain useful.
+ var workloadIDAttrs *workloadidentityv1.JoinAttrs
+ if claims != nil {
+ workloadIDAttrs = &workloadidentityv1.JoinAttrs{
+ Gcp: claims.JoinAttrs(),
+ }
+ }
+
+ return claims, workloadIDAttrs, trace.Wrap(err)
+}