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) +}