Port gcp join method to new join service (#61205)

* Port `gcp` join method to new join service

This ports the `gcp` join method to the new join service. It moves
join-related code from `lib/gcp` to `lib/join/gcp`, extracts common
joining logic to `lib/join/gcp` for reuse between the legacy and new
entrypoints, and adds a compatibility layer for use by the legacy
client.

See also: [RFD 27e](https://github.com/gravitational/teleport.e/blob/master/rfd/0027e-auth-assigned-uuids.md)

* Add GCP to new join service whitelist

* Rename checkAndSetDefaults() to validate()
This commit is contained in:
Tim Buckley
2025-11-18 00:46:04 +00:00
committed by GitHub
parent 21d1d0be59
commit b71bb3871e
13 changed files with 476 additions and 288 deletions
+2 -2
View File
@@ -111,7 +111,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"
@@ -120,6 +119,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"
kubetoken "github.com/gravitational/teleport/lib/kube/token"
@@ -1293,7 +1293,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
-8
View File
@@ -205,10 +205,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
}
@@ -361,10 +357,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)
}
+5 -3
View File
@@ -346,9 +346,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()
+18 -112
View File
@@ -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 := gcp.CheckIDToken(ctx, &gcp.CheckIDTokenParams{
ProvisionToken: pt,
IDToken: []byte(req.IDToken),
Validator: a.gcpIDTokenValidator,
})
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
// 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)
}
-74
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
+224
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
+97
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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))
})
}
}
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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,18 +66,19 @@ func TestAuth_RegisterUsingToken_GCP(t *testing.T) {
},
},
}
var withTokenValidator auth.ServerOption = func(server *auth.Server) error {
server.SetGCPIDTokenValidator(idTokenValidator)
return nil
}
ctx := t.Context()
p, err := newTestPack(ctx, testPackOptions{
DataDir: t.TempDir(),
MutateAuth: withTokenValidator,
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()
@@ -129,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},
@@ -146,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},
@@ -162,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},
@@ -178,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},
@@ -194,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},
@@ -219,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
}
})
})
}
}
+11
View File
@@ -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/bitbucket"
"github.com/gravitational/teleport/lib/join/env0"
@@ -202,6 +203,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,
@@ -319,6 +321,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)
+4
View File
@@ -47,6 +47,7 @@ import (
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/join/bitbucket"
"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"
@@ -85,6 +86,7 @@ type AuthService interface {
GetBitbucketIDTokenValidator() bitbucket.Validator
GetEC2ClientForEC2JoinMethod() ec2join.EC2Client
GetEnv0IDTokenValidator() Env0TokenValidator
GetGCPIDTokenValidator() gcp.Validator
GetGHAIDTokenValidator() githubactions.GithubIDTokenValidator
GetGHAIDTokenJWKSValidator() githubactions.GithubIDTokenJWKSValidator
GetGitlabIDTokenValidator() gitlab.Validator
@@ -294,6 +296,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:
+55
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}