diff --git a/api/client/client.go b/api/client/client.go
index d2e2f59b463d7..fdda50ecfa4ca 100644
--- a/api/client/client.go
+++ b/api/client/client.go
@@ -95,6 +95,7 @@ import (
resourceusagepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/resourceusage/v1"
samlidppb "github.com/gravitational/teleport/api/gen/proto/go/teleport/samlidp/v1"
scopedaccessv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/access/v1"
+ joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1"
secreportsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/secreports/v1"
stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1"
summarizerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/summarizer/v1"
@@ -134,6 +135,7 @@ type AuthServiceClient struct {
userpreferencespb.UserPreferencesServiceClient
notificationsv1pb.NotificationServiceClient
recordingencryptionv1pb.RecordingEncryptionServiceClient
+ joiningv1.ScopedJoiningServiceClient
}
// Client is a gRPC Client that connects to a Teleport Auth server either
@@ -547,6 +549,7 @@ func (c *Client) dialGRPC(ctx context.Context, addr string) error {
UserPreferencesServiceClient: userpreferencespb.NewUserPreferencesServiceClient(c.conn),
NotificationServiceClient: notificationsv1pb.NewNotificationServiceClient(c.conn),
RecordingEncryptionServiceClient: recordingencryptionv1pb.NewRecordingEncryptionServiceClient(c.conn),
+ ScopedJoiningServiceClient: joiningv1.NewScopedJoiningServiceClient(c.conn),
}
c.JoinServiceClient = NewJoinServiceClient(proto.NewJoinServiceClient(c.conn))
@@ -5811,3 +5814,25 @@ func (c *Client) ValidateTrustedCluster(
}
return resp, nil
}
+
+// ListScopedTokens fetches pages of scoped tokens.
+func (c *Client) ListScopedTokens(ctx context.Context, req *joiningv1.ListScopedTokensRequest) (*joiningv1.ListScopedTokensResponse, error) {
+ res, err := c.grpc.ListScopedTokens(ctx, req)
+ return res, trace.Wrap(err)
+}
+
+// DeleteScopedToken deletes an existing scoped token.
+func (c *Client) DeleteScopedToken(ctx context.Context, name string) error {
+ _, err := c.grpc.DeleteScopedToken(ctx, &joiningv1.DeleteScopedTokenRequest{
+ Name: name,
+ })
+ return trace.Wrap(err)
+}
+
+// CreateScopedToken creates a new scoped token.
+func (c *Client) CreateScopedToken(ctx context.Context, token *joiningv1.ScopedToken) (*joiningv1.ScopedToken, error) {
+ res, err := c.grpc.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{
+ Token: token,
+ })
+ return res.GetToken(), trace.Wrap(err)
+}
diff --git a/lib/auth/scopes/joining/service.go b/lib/auth/scopes/joining/service.go
index 8de0e110b6772..fddf9a29a7fe9 100644
--- a/lib/auth/scopes/joining/service.go
+++ b/lib/auth/scopes/joining/service.go
@@ -94,6 +94,10 @@ func (s *Server) CreateScopedToken(ctx context.Context, req *scopedjoiningv1.Cre
token.Metadata.Name = name
}
+ if token.GetSpec() != nil && token.GetSpec().GetJoinMethod() == "" {
+ token.Spec.JoinMethod = string(types.JoinMethodToken)
+ }
+
res, err := s.backend.CreateScopedToken(ctx, req)
return res, trace.Wrap(err)
}
diff --git a/lib/join/token/scoped.go b/lib/join/token/scoped.go
new file mode 100644
index 0000000000000..b04669ceecbc5
--- /dev/null
+++ b/lib/join/token/scoped.go
@@ -0,0 +1,108 @@
+/*
+ * 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 token
+
+import (
+ "strings"
+ "time"
+
+ "github.com/gravitational/trace"
+
+ joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1"
+ "github.com/gravitational/teleport/api/types"
+)
+
+// Scoped wraps a [joiningv1.ScopedToken] such that it can be used to provision
+// resources.
+type Scoped struct {
+ token *joiningv1.ScopedToken
+ joinMethod types.JoinMethod
+ roles types.SystemRoles
+}
+
+// NewScoped returns the wrapped version of the given [joiningv1.ScopedToken].
+// It will return an error if the configured join method is not a valid
+// [types.JoinMethod] or if any of the configured roles are not a valid
+// [types.SystemRole]. The validated join method and roles are cached on the
+// [Scoped] wrapper itself so they can be read without repeating validation.
+func NewScoped(token *joiningv1.ScopedToken) (*Scoped, error) {
+ joinMethod := types.JoinMethod(token.GetSpec().GetJoinMethod())
+ if err := types.ValidateJoinMethod(joinMethod); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ roles, err := types.NewTeleportRoles(token.GetSpec().GetRoles())
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return &Scoped{token: token, joinMethod: joinMethod, roles: roles}, nil
+}
+
+// GetName returns the name of a [joiningv1.ScopedToken].
+func (s *Scoped) GetName() string {
+ return s.token.GetMetadata().GetName()
+}
+
+// GetJoinMethod returns the cached [types.JoinMethod] generated when the
+// [joiningv1.ScopedToken] was wrapped.
+func (s *Scoped) GetJoinMethod() types.JoinMethod {
+ return s.joinMethod
+}
+
+// GetRoles returns the cached [types.SystemRoles] generated when the
+// [joiningv1.ScopedToken] was wrapped.
+func (s *Scoped) GetRoles() types.SystemRoles {
+ return s.roles
+}
+
+// GetSafeName returns the name of the scoped token, sanitized appropriately
+// for join methods where the name is secret. This should be used when logging
+// the token name.
+func (s *Scoped) GetSafeName() string {
+ return GetSafeScopedTokenName(s.token)
+}
+
+// Expiry returns the [time.Time] representing when the wrapped
+// [joiningv1.ScopedToken] will expire.
+func (s *Scoped) Expiry() time.Time {
+ return s.token.GetMetadata().GetExpires().AsTime()
+}
+
+// GetSafeScopedTokenName returns the name of the scoped token, sanitized
+// appropriately for join methods where the name is secret. This should be used
+// when logging the token name.
+func GetSafeScopedTokenName(token *joiningv1.ScopedToken) string {
+ name := token.GetMetadata().GetName()
+ if types.JoinMethod(token.GetSpec().GetJoinMethod()) != types.JoinMethodToken {
+ return name
+ }
+
+ // If the token name is short, we just blank the whole thing.
+ if len(name) < 16 {
+ return strings.Repeat("*", len(name))
+ }
+
+ // If the token name is longer, we can show the last 25% of it to help
+ // the operator identify it.
+ hiddenBefore := int(0.75 * float64(len(name)))
+ name = name[hiddenBefore:]
+ name = strings.Repeat("*", hiddenBefore) + name
+ return name
+}
diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go
index 21cf58600a901..4621c87aaddf6 100644
--- a/tool/tctl/common/cmds.go
+++ b/tool/tctl/common/cmds.go
@@ -73,5 +73,6 @@ func Commands() []CLICommand {
&stableunixusers.Command{},
&decision.Command{},
&BoundKeypairCommand{},
+ &ScopedCommand{},
}
}
diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go
index 8866604027cb7..bc734f9ff7f2d 100644
--- a/tool/tctl/common/helpers_test.go
+++ b/tool/tctl/common/helpers_test.go
@@ -120,6 +120,16 @@ func runTokensCommand(t *testing.T, client *authclient.Client, args []string) (*
return &stdoutBuff, runCommand(t, client, command, args)
}
+func runScopedCommand(t *testing.T, client *authclient.Client, args []string) (*bytes.Buffer, error) {
+ var stdoutBuff bytes.Buffer
+ command := &ScopedCommand{
+ Stdout: &stdoutBuff,
+ }
+
+ args = append([]string{"scoped"}, args...)
+ return &stdoutBuff, runCommand(t, client, command, args)
+}
+
func runUserCommand(t *testing.T, client *authclient.Client, args []string) error {
command := &UserCommand{}
args = append([]string{"users"}, args...)
diff --git a/tool/tctl/common/scoped_command.go b/tool/tctl/common/scoped_command.go
new file mode 100644
index 0000000000000..4d5d0983530f6
--- /dev/null
+++ b/tool/tctl/common/scoped_command.go
@@ -0,0 +1,60 @@
+/*
+ * 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 common
+
+import (
+ "context"
+ "io"
+ "os"
+
+ "github.com/alecthomas/kingpin/v2"
+
+ "github.com/gravitational/teleport/lib/service/servicecfg"
+ commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
+ tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
+)
+
+// ScopedCommand implements scoped variants of tctl command groups, such as
+// `tctl scoped tokens`.
+type ScopedCommand struct {
+ config *servicecfg.Config
+ tokens *ScopedTokensCommand
+ Stdout io.Writer
+}
+
+// Initialize allows ScopedCommand to plug itself into the CLI parser
+func (c *ScopedCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) {
+ c.config = config
+ scoped := app.Command("scoped", "Run a subcommand using scoped auth")
+
+ if c.Stdout == nil {
+ c.Stdout = os.Stdout
+ }
+
+ c.tokens = &ScopedTokensCommand{
+ Stdout: c.Stdout,
+ }
+
+ c.tokens.Initialize(scoped, config)
+}
+
+// TryRun takes the CLI command as an argument (like "scoped tokens") and executes it.
+func (c *ScopedCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) {
+ return c.tokens.TryRun(ctx, cmd, clientFunc)
+}
diff --git a/tool/tctl/common/scoped_token_command.go b/tool/tctl/common/scoped_token_command.go
new file mode 100644
index 0000000000000..8564574d0edfa
--- /dev/null
+++ b/tool/tctl/common/scoped_token_command.go
@@ -0,0 +1,310 @@
+/*
+ * 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 common
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+ "google.golang.org/protobuf/types/known/timestamppb"
+ "gopkg.in/yaml.v3"
+
+ "github.com/gravitational/teleport"
+ headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
+ joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/api/utils/clientutils"
+ "github.com/gravitational/teleport/lib/asciitable"
+ "github.com/gravitational/teleport/lib/auth/authclient"
+ "github.com/gravitational/teleport/lib/defaults"
+ "github.com/gravitational/teleport/lib/itertools/stream"
+ "github.com/gravitational/teleport/lib/join/token"
+ "github.com/gravitational/teleport/lib/service/servicecfg"
+ "github.com/gravitational/teleport/lib/utils"
+ commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
+)
+
+// ScopedTokensCommand implements `tctl scoped tokens` group of commands
+type ScopedTokensCommand struct {
+ config *servicecfg.Config
+
+ withSecrets bool
+
+ // format is the output format, e.g. text or json
+ format string
+
+ // tokenType is the type of token. For example, "node".
+ tokenType string
+
+ // Value is the value of the token. Can be used to either act on a
+ // token (for example, delete a token) or used to create a token with a
+ // specific value.
+ value string
+
+ // assignedScope allows for filtering tokens by the scope they assign
+ assignedScope string
+
+ // tokenScope allows for filtering tokens by the scope they belong to
+ tokenScope string
+
+ // ttl is how long the token will live for.
+ ttl time.Duration
+
+ // tokenAdd is used to add a token.
+ tokenAdd *kingpin.CmdClause
+
+ // tokenDel is used to delete a token.
+ tokenDel *kingpin.CmdClause
+
+ // tokenList is used to view all tokens that Teleport knows about.
+ tokenList *kingpin.CmdClause
+
+ // Stdout allows to switch the standard output source. Used in tests.
+ Stdout io.Writer
+}
+
+// Initialize allows TokenCommand to plug itself into the CLI parser
+func (c *ScopedTokensCommand) Initialize(scopedCmd *kingpin.CmdClause, config *servicecfg.Config) {
+ c.config = config
+
+ tokens := scopedCmd.Command("tokens", "List or revoke scoped invitation tokens")
+
+ formats := []string{teleport.Text, teleport.JSON, teleport.YAML}
+
+ // tctl scoped tokens add ..."
+ c.tokenAdd = tokens.Command("add", "Create a scoped invitation token.")
+ c.tokenAdd.Flag("type", "Type(s) of token to add, e.g. --type=node").Required().StringVar(&c.tokenType)
+ c.tokenAdd.Flag("value", "Override the default random generated token with a specified value").StringVar(&c.value)
+ c.tokenAdd.Flag("ttl", fmt.Sprintf("Set expiration time for token, default is %v minutes",
+ int(defaults.ProvisioningTokenTTL/time.Minute))).
+ Default(fmt.Sprintf("%v", defaults.ProvisioningTokenTTL)).
+ DurationVar(&c.ttl)
+ c.tokenAdd.Flag("format", "Output format, 'text', 'json', or 'yaml'").EnumVar(&c.format, formats...)
+ c.tokenAdd.Flag("assign-scope", "Scope that should be applied to resources provisioned by this token").StringVar(&c.assignedScope)
+ c.tokenAdd.Flag("scope", "Scope assigned to the token itself").StringVar(&c.tokenScope)
+
+ // "tctl scoped tokens rm ..."
+ c.tokenDel = tokens.Command("rm", "Delete/revoke a scoped invitation token.").Alias("del")
+ c.tokenDel.Arg("token", "Token to delete").StringVar(&c.value)
+
+ // "tctl scoped tokens ls"
+ c.tokenList = tokens.Command("ls", "List invitation tokens.")
+ c.tokenList.Flag("format", "Output format, 'text', 'json' or 'yaml'").EnumVar(&c.format, formats...)
+ c.tokenList.Flag("with-secrets", "Do not redact join tokens").BoolVar(&c.withSecrets)
+
+ if c.Stdout == nil {
+ c.Stdout = os.Stdout
+ }
+}
+
+// TryRun attempts to run subcommands like like "scoped tokens ls".
+func (c *ScopedTokensCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) {
+ var commandFunc func(ctx context.Context, client *authclient.Client) error
+ switch cmd {
+ case c.tokenAdd.FullCommand():
+ commandFunc = c.Add
+ case c.tokenDel.FullCommand():
+ commandFunc = c.Del
+ case c.tokenList.FullCommand():
+ commandFunc = c.List
+ default:
+ return false, nil
+ }
+ client, closeFn, err := clientFunc(ctx)
+ if err != nil {
+ return false, trace.Wrap(err)
+ }
+ err = commandFunc(ctx, client)
+ closeFn(ctx)
+
+ return true, trace.Wrap(err)
+}
+
+// Add is called to execute "scoped tokens add ..." command.
+func (c *ScopedTokensCommand) Add(ctx context.Context, client *authclient.Client) error {
+ // Parse string to see if it's a type of role that Teleport supports.
+ roles, err := types.ParseTeleportRoles(c.tokenType)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ // If it's Kube, then enable App and Discovery roles automatically so users
+ // don't have problems with running Kubernetes App Discovery by default.
+ if len(roles) == 1 && roles[0] == types.RoleKube {
+ roles = append(roles, types.RoleApp, types.RoleDiscovery)
+ }
+
+ tokenName := c.value
+
+ expires := time.Now().UTC().Add(c.ttl)
+ tok := &joiningv1.ScopedToken{
+ Kind: types.KindScopedToken,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: tokenName,
+ Expires: timestamppb.New(expires),
+ },
+ Scope: c.tokenScope,
+ Spec: &joiningv1.ScopedTokenSpec{
+ Roles: roles.StringSlice(),
+ AssignedScope: c.assignedScope,
+ },
+ }
+
+ tok, err = client.CreateScopedToken(ctx, tok)
+ if err != nil {
+ if trace.IsAlreadyExists(err) {
+ return trace.AlreadyExists(
+ "failed to create scoped token (%q already exists), please use another name",
+ tokenName,
+ )
+ }
+ return trace.Wrap(err, "creating scoped token")
+ }
+
+ tokenName = tok.GetMetadata().GetName()
+ // Print token information formatted with JSON, YAML, or just print the raw token.
+ switch c.format {
+ case teleport.JSON, teleport.YAML:
+ expires := time.Now().Add(c.ttl)
+ tokenInfo := map[string]any{
+ "token": tokenName,
+ "roles": roles,
+ "scope": tok.GetScope(),
+ "assign_scope": tok.GetSpec().GetAssignedScope(),
+ "expires": expires,
+ }
+
+ var (
+ data []byte
+ err error
+ )
+ if c.format == teleport.JSON {
+ data, err = json.MarshalIndent(tokenInfo, "", " ")
+ } else {
+ data, err = yaml.Marshal(tokenInfo)
+ }
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ fmt.Fprint(c.Stdout, string(data))
+
+ return nil
+ case teleport.Text:
+ fmt.Fprintln(c.Stdout, tokenName)
+ return nil
+ }
+
+ return trace.Wrap(showJoinInstructions(ctx, joinInstructionsInput{
+ out: c.Stdout,
+ ttl: c.ttl,
+ roles: roles,
+ tokenName: tokenName,
+ client: client,
+ }))
+}
+
+// Del is called to execute "scoped tokens del ..." command.
+func (c *ScopedTokensCommand) Del(ctx context.Context, client *authclient.Client) error {
+ if c.value == "" {
+ return trace.BadParameter("Need an argument: token")
+ }
+ if err := client.DeleteScopedToken(ctx, c.value); err != nil {
+ return trace.Wrap(err)
+ }
+ fmt.Fprintf(c.Stdout, "Token %s has been deleted\n", c.value)
+ return nil
+}
+
+// List is called to execute "tokens ls" command.
+func (c *ScopedTokensCommand) List(ctx context.Context, client *authclient.Client) error {
+ tokens, err := stream.Collect(clientutils.Resources(ctx, func(ctx context.Context, pageSize int, pageKey string) ([]*joiningv1.ScopedToken, string, error) {
+ res, err := client.ListScopedTokens(ctx, &joiningv1.ListScopedTokensRequest{
+ Limit: uint32(pageSize),
+ Cursor: pageKey,
+ })
+ if err != nil {
+ return nil, "", trace.Wrap(err)
+ }
+
+ return res.GetTokens(), res.GetCursor(), nil
+ }))
+ if err != nil {
+ return trace.Wrap(err, "listing scoped tokens")
+ }
+
+ if len(tokens) == 0 && c.format == teleport.Text {
+ fmt.Fprintln(c.Stdout, "No active tokens found.")
+ return nil
+ }
+
+ // Sort by expire time.
+ slices.SortStableFunc(tokens, func(left, right *joiningv1.ScopedToken) int {
+ return left.GetMetadata().GetExpires().AsTime().Compare(right.GetMetadata().GetExpires().AsTime())
+ })
+
+ nameFunc := func(tok *joiningv1.ScopedToken) string {
+ if c.withSecrets {
+ return tok.GetMetadata().GetName()
+ }
+ return token.GetSafeScopedTokenName(tok)
+ }
+ switch c.format {
+ case teleport.JSON:
+ err := utils.WriteJSONArray(c.Stdout, tokens)
+ if err != nil {
+ return trace.Wrap(err, "failed to marshal tokens")
+ }
+ case teleport.YAML:
+ err := utils.WriteYAML(c.Stdout, tokens)
+ if err != nil {
+ return trace.Wrap(err, "failed to marshal tokens")
+ }
+ case teleport.Text:
+ for _, token := range tokens {
+ fmt.Fprintln(c.Stdout, nameFunc(token))
+ }
+ default:
+ tokensView := func() string {
+ table := asciitable.MakeTable([]string{"Token", "Type", "Scope", "Assigns Scope", "Labels", "Expiry Time (UTC)"})
+ now := time.Now()
+ for _, t := range tokens {
+ expiry := "never"
+ expiresAt := t.GetMetadata().GetExpires().AsTime()
+ if !expiresAt.IsZero() && expiresAt.Unix() != 0 {
+ exptime := expiresAt.Format(time.RFC822)
+ expdur := expiresAt.Sub(now).Round(time.Second)
+ expiry = fmt.Sprintf("%s (%s)", exptime, expdur.String())
+ }
+ table.AddRow([]string{nameFunc(t), strings.Join(t.GetSpec().GetRoles(), ","), t.GetScope(), t.GetSpec().GetAssignedScope(), printMetadataLabels(t.GetMetadata().Labels), expiry})
+ }
+ return table.AsBuffer().String()
+ }
+ fmt.Fprint(c.Stdout, tokensView())
+ }
+ return nil
+}
diff --git a/tool/tctl/common/scoped_token_command_test.go b/tool/tctl/common/scoped_token_command_test.go
new file mode 100644
index 0000000000000..95f2c34981fb3
--- /dev/null
+++ b/tool/tctl/common/scoped_token_command_test.go
@@ -0,0 +1,125 @@
+/*
+ * 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 common
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/integration/helpers"
+ "github.com/gravitational/teleport/lib/config"
+ "github.com/gravitational/teleport/tool/teleport/testenv"
+)
+
+type listedScopedToken struct {
+ Kind string
+ Version string
+ Metadata struct {
+ Name string
+ Expires timestamppb.Timestamp
+ ID uint
+ }
+ Spec struct {
+ Roles []string
+ JoinMethod string
+ }
+}
+
+func TestScopedTokens(t *testing.T) {
+ dynAddr := helpers.NewDynamicServiceAddr(t)
+ fileConfig := &config.FileConfig{
+ Global: config.Global{
+ DataDir: t.TempDir(),
+ },
+ Apps: config.Apps{
+ Service: config.Service{
+ EnabledFlag: "true",
+ },
+ },
+ Proxy: config.Proxy{
+ Service: config.Service{
+ EnabledFlag: "true",
+ },
+ WebAddr: dynAddr.WebAddr,
+ TunAddr: dynAddr.TunnelAddr,
+ },
+ Auth: config.Auth{
+ Service: config.Service{
+ EnabledFlag: "true",
+ ListenAddress: dynAddr.AuthAddr,
+ },
+ },
+ }
+
+ process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors))
+ clt, err := testenv.NewDefaultAuthClient(process)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = clt.Close() })
+
+ scopeFlags := []string{"--scope=/aa", "--assign-scope=/aa/bb"}
+ // Test all output formats of "tokens add".
+ buf, err := runScopedCommand(t, clt, append([]string{"tokens", "add", "--type=node"}, scopeFlags...))
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(buf.String(), "The invite token:"))
+
+ buf, err = runScopedCommand(t, clt, append([]string{"tokens", "add", "--type=node", "--format", teleport.Text}, scopeFlags...))
+ require.NoError(t, err)
+ require.Equal(t, 1, strings.Count(buf.String(), "\n"))
+
+ buf, err = runScopedCommand(t, clt, append([]string{"tokens", "add", "--type=node", "--format", teleport.JSON}, scopeFlags...))
+ require.NoError(t, err)
+ out := mustDecodeJSON[addedToken](t, buf)
+
+ require.Len(t, out.Roles, 1)
+ require.Equal(t, types.KindNode, strings.ToLower(out.Roles[0]))
+
+ buf, err = runScopedCommand(t, clt, append([]string{"tokens", "add", "--type=node", "--format", teleport.YAML}, scopeFlags...))
+ require.NoError(t, err)
+ out = mustDecodeYAML[addedToken](t, buf)
+
+ require.Len(t, out.Roles, 1)
+ require.Equal(t, types.KindNode, strings.ToLower(out.Roles[0]))
+
+ // Test all output formats of "tokens ls".
+ buf, err = runScopedCommand(t, clt, []string{"tokens", "ls"})
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(buf.String(), "Token "))
+ require.Equal(t, 6, strings.Count(buf.String(), "\n")) // account for header lines
+
+ buf, err = runScopedCommand(t, clt, []string{"tokens", "ls", "--format", teleport.Text})
+ require.NoError(t, err)
+ require.Equal(t, 4, strings.Count(buf.String(), "\n"))
+
+ buf, err = runScopedCommand(t, clt, []string{"tokens", "ls", "--format", teleport.JSON})
+ require.NoError(t, err)
+ jsonOut := mustDecodeJSON[[]listedScopedToken](t, buf)
+ require.Len(t, jsonOut, 4)
+
+ buf, err = runScopedCommand(t, clt, []string{"tokens", "ls", "--format", teleport.YAML})
+ require.NoError(t, err)
+ yamlOut := []listedScopedToken{}
+ mustDecodeYAMLDocuments(t, buf, &yamlOut)
+ require.Len(t, yamlOut, 4)
+ require.Equal(t, jsonOut, yamlOut)
+}
diff --git a/tool/tctl/common/token_command.go b/tool/tctl/common/token_command.go
index c718c76819fc0..52a29fc221c85 100644
--- a/tool/tctl/common/token_command.go
+++ b/tool/tctl/common/token_command.go
@@ -305,128 +305,18 @@ func (c *TokensCommand) Add(ctx context.Context, client *authclient.Client) erro
return nil
}
- // Calculate the CA pins for this cluster. The CA pins are used by the
- // client to verify the identity of the Auth Server.
- localCAResponse, err := client.GetClusterCACert(ctx)
- if err != nil {
- return trace.Wrap(err)
- }
- caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA)
- if err != nil {
- return trace.Wrap(err)
- }
-
- // Get list of auth servers. Used to print friendly signup message.
- authServers, err := client.GetAuthServers()
- if err != nil {
- return trace.Wrap(err)
- }
- if len(authServers) == 0 {
- return trace.BadParameter("this cluster has no auth servers")
- }
-
- // Print signup message.
- switch {
- case roles.Include(types.RoleKube):
- proxies, err := client.GetProxies()
- if err != nil {
- return trace.Wrap(err)
- }
- if len(proxies) == 0 {
- return trace.NotFound("cluster has no proxies")
- }
- setRoles := strings.ToLower(strings.Join(roles.StringSlice(), "\\,"))
- return kubeMessageTemplate.Execute(c.Stdout,
- map[string]any{
- "auth_server": proxies[0].GetPublicAddr(),
- "token": token,
- "minutes": c.ttl.Minutes(),
- "set_roles": setRoles,
- "version": proxies[0].GetTeleportVersion(),
- })
- case roles.Include(types.RoleApp):
- proxies, err := client.GetProxies()
- if err != nil {
- return trace.Wrap(err)
- }
- if len(proxies) == 0 {
- return trace.BadParameter("cluster has no proxies")
- }
- appPublicAddr := fmt.Sprintf("%v.%v", c.appName, proxies[0].GetPublicAddr())
-
- return appMessageTemplate.Execute(c.Stdout,
- map[string]any{
- "token": token,
- "minutes": c.ttl.Minutes(),
- "ca_pins": caPins,
- "auth_server": proxies[0].GetPublicAddr(),
- "app_name": c.appName,
- "app_uri": c.appURI,
- "app_public_addr": appPublicAddr,
- })
- case roles.Include(types.RoleDatabase):
- proxies, err := client.GetProxies()
- if err != nil {
- return trace.Wrap(err)
- }
- if len(proxies) == 0 {
- return trace.NotFound("cluster has no proxies")
- }
- return dbMessageTemplate.Execute(c.Stdout,
- map[string]any{
- "token": token,
- "minutes": c.ttl.Minutes(),
- "ca_pins": caPins,
- "auth_server": proxies[0].GetPublicAddr(),
- "db_name": c.dbName,
- "db_protocol": c.dbProtocol,
- "db_uri": c.dbURI,
- })
- case roles.Include(types.RoleTrustedCluster):
- fmt.Fprintf(c.Stdout, trustedClusterMessage,
- token,
- int(c.ttl.Minutes()))
- case roles.Include(types.RoleWindowsDesktop):
- return desktopMessageTemplate.Execute(c.Stdout,
- map[string]any{
- "token": token,
- "minutes": c.ttl.Minutes(),
- })
- case roles.Include(types.RoleMDM):
- return mdmTokenAddTemplate.Execute(c.Stdout, map[string]any{
- "token": token,
- "minutes": c.ttl.Minutes(),
- "ca_pins": caPins,
- })
- default:
- authServer := authServers[0].GetAddr()
-
- pingResponse, err := client.Ping(ctx)
- if err != nil {
- slog.DebugContext(ctx, "unable to ping auth client", "error", err)
- }
-
- if err == nil && pingResponse.GetServerFeatures().Cloud {
- proxies, err := client.GetProxies()
- if err != nil {
- return trace.Wrap(err)
- }
-
- if len(proxies) != 0 {
- authServer = proxies[0].GetPublicAddr()
- }
- }
-
- return nodeMessageTemplate.Execute(c.Stdout, map[string]any{
- "token": token,
- "roles": strings.ToLower(roles.String()),
- "minutes": int(c.ttl.Minutes()),
- "ca_pins": caPins,
- "auth_server": authServer,
- })
- }
-
- return nil
+ return trace.Wrap(showJoinInstructions(ctx, joinInstructionsInput{
+ out: c.Stdout,
+ client: client,
+ roles: roles,
+ tokenName: token,
+ ttl: c.ttl,
+ appName: c.appName,
+ appURI: c.appURI,
+ dbName: c.dbName,
+ dbURI: c.dbURI,
+ dbProtocol: c.dbProtocol,
+ }))
}
// Del is called to execute "tokens del ..." command.
@@ -1064,3 +954,141 @@ func generateAgentValues(params valueGeneratorParams) ([]byte, error) {
return yaml.Marshal(agentValues)
}
+
+type joinInstructionsInput struct {
+ client *authclient.Client
+ roles types.SystemRoles
+ out io.Writer
+ tokenName string
+ ttl time.Duration
+ appName string
+ appURI string
+ dbName string
+ dbURI string
+ dbProtocol string
+}
+
+func showJoinInstructions(ctx context.Context, in joinInstructionsInput) error {
+ // Calculate the CA pins for this cluster. The CA pins are used by the
+ // client to verify the identity of the Auth Server.
+ localCAResponse, err := in.client.GetClusterCACert(ctx)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ // Get list of auth servers. Used to print friendly signup message.
+ authServers, err := in.client.GetAuthServers()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if len(authServers) == 0 {
+ return trace.BadParameter("this cluster has no auth servers")
+ }
+
+ // Print signup message.
+ switch {
+ case in.roles.Include(types.RoleKube):
+ proxies, err := in.client.GetProxies()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if len(proxies) == 0 {
+ return trace.NotFound("cluster has no proxies")
+ }
+ setRoles := strings.ToLower(strings.Join(in.roles.StringSlice(), "\\,"))
+ return kubeMessageTemplate.Execute(in.out,
+ map[string]any{
+ "auth_server": proxies[0].GetPublicAddr(),
+ "token": in.tokenName,
+ "minutes": in.ttl.Minutes(),
+ "set_roles": setRoles,
+ "version": proxies[0].GetTeleportVersion(),
+ })
+ case in.roles.Include(types.RoleApp):
+ proxies, err := in.client.GetProxies()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if len(proxies) == 0 {
+ return trace.BadParameter("cluster has no proxies")
+ }
+ appPublicAddr := fmt.Sprintf("%v.%v", in.appName, proxies[0].GetPublicAddr())
+
+ return appMessageTemplate.Execute(in.out,
+ map[string]any{
+ "token": in.tokenName,
+ "minutes": in.ttl.Minutes(),
+ "ca_pins": caPins,
+ "auth_server": proxies[0].GetPublicAddr(),
+ "app_name": in.appName,
+ "app_uri": in.appURI,
+ "app_public_addr": appPublicAddr,
+ })
+ case in.roles.Include(types.RoleDatabase):
+ proxies, err := in.client.GetProxies()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if len(proxies) == 0 {
+ return trace.NotFound("cluster has no proxies")
+ }
+ return dbMessageTemplate.Execute(in.out,
+ map[string]any{
+ "token": in.tokenName,
+ "minutes": in.ttl.Minutes(),
+ "ca_pins": caPins,
+ "auth_server": proxies[0].GetPublicAddr(),
+ "db_name": in.dbName,
+ "db_protocol": in.dbProtocol,
+ "db_uri": in.dbURI,
+ })
+ case in.roles.Include(types.RoleTrustedCluster):
+ fmt.Fprintf(in.out, trustedClusterMessage,
+ in.tokenName,
+ int(in.ttl.Minutes()))
+ case in.roles.Include(types.RoleWindowsDesktop):
+ return desktopMessageTemplate.Execute(in.out,
+ map[string]any{
+ "token": in.tokenName,
+ "minutes": in.ttl.Minutes(),
+ })
+ case in.roles.Include(types.RoleMDM):
+ return mdmTokenAddTemplate.Execute(in.out, map[string]any{
+ "token": in.tokenName,
+ "minutes": in.ttl.Minutes(),
+ "ca_pins": caPins,
+ })
+ default:
+ authServer := authServers[0].GetAddr()
+
+ pingResponse, err := in.client.Ping(ctx)
+ if err != nil {
+ slog.DebugContext(ctx, "unable to ping auth client", "error", err)
+ }
+
+ if err == nil && pingResponse.GetServerFeatures().Cloud {
+ proxies, err := in.client.GetProxies()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ if len(proxies) != 0 {
+ authServer = proxies[0].GetPublicAddr()
+ }
+ }
+
+ return nodeMessageTemplate.Execute(in.out, map[string]any{
+ "token": in.tokenName,
+ "roles": strings.ToLower(in.roles.String()),
+ "minutes": int(in.ttl.Minutes()),
+ "ca_pins": caPins,
+ "auth_server": authServer,
+ })
+ }
+
+ return nil
+}