Improve Scoped Token UX (#67819)

Removes any externally visibile notions to end users that a
token name and token secret are two different entities. This
removes the need to output a token_name and token_secret value
from `tctl scoped tokens add` as well as removing the token_secret
from teleport.yaml and teleport CLIs.

Internally, the name of a scoped_token is still NOT a secret. The
secret is a separate field in the scoped_token.status. When the
token is presented to a user it is done so as `<token_name>:<base64(token_secret)`.
The leading token_name is not encoded to allow users to visually
dentify tokens they may have created with a specific name. The `~`
separator was chosen because it is neither a valid backend key, scope
separator, and does not need any special encoding if included in a URL.
The token_secret is base64 encoded so that it appears as opaque text
to users and can be included in a URL.

Additionally, the tctl scoped ref parsing has been updated such
that tctl get/edit/rm honors both `<token_name>` and `<token_name>:<base64(token_secret)>`

This does contain a few breaking changes. The token_secret has been removed
from the teleport.yaml file config and its equivalent CLI flags have been
removed. Since scopes are still under active development and this only impacts
scoped_tokens and not traditional tokens the impact of this change should be
minimal.
This commit is contained in:
rosstimothy
2026-06-18 12:17:40 -04:00
committed by GitHub
parent d5feb757cd
commit 510859bfb8
16 changed files with 131 additions and 103 deletions
-2
View File
@@ -802,7 +802,6 @@ Flags:
|`--sshd-check-command`|`sshd -t -f`|Command to use when checking OpenSSH config for validity. (sshd -t -f \<sshd_config\>)|
|`--sshd-restart-command`|*no default*|Command to use when restarting openssh.|
|`--token`|*no default*|Invitation token or path to file with token value to register with an auth server.|
|`--token-secret`|*no default*|Invitation token secret or path to file with secret value. Used to register with an auth server \[none\]|
## teleport node configure
@@ -867,7 +866,6 @@ Flags:
|`--pid-file`|*no default*|Full path to the PID file. By default no PID file will be created|
|`-r`, `--roles`|*no default*|Comma-separated list of roles to start with \[proxy,node,auth,app,db\]|
|`--token`|*no default*|Invitation token or path to file with token value. Used to register with an auth server \[none\]|
|`--token-secret`|*no default*|Invitation token secret or path to file with secret value. Used to register with an auth server \[none\]|
## teleport status
@@ -310,8 +310,7 @@ Run this on the new node to join the cluster:
> teleport start \
--roles=node \
--token=019ce8f7-f0d5-784d-af3f-075aa79c19cb \
--token-secret=d5038b4ea0a733cccfc3f0f48ee04baf \
--token=019ce8f7-f0d5-784d-af3f-075aa79c19cb~YzZlYzdlYTg1YTZjZTEwZjVjYjAxMTc3Mjc2NTk3NGY \
--auth-server=proxy.example.com:443
```
@@ -334,8 +333,7 @@ spec:
roles:
- Node
usage_mode: unlimited
status:
secret: '******'
status: {}
version: v1
```
@@ -395,7 +393,7 @@ spec:
- Node
usage_mode: single_use
status:
secret: '******'
secret: {}
usage:
single_use:
reusable_until: "2026-03-13T21:44:05.695268Z"
-13
View File
@@ -83,8 +83,6 @@ type CommandLineFlags struct {
AuthServerAddr []string
// --token flag
AuthToken string
// --token-secret flag
TokenSecret string
// --join-method flag
JoinMethod string
// CAPins are the SKPI hashes of the CAs used to verify the Auth Server.
@@ -2966,11 +2964,6 @@ func Configure(clf *CommandLineFlags, cfg *servicecfg.Config, legacyAppFlags boo
cfg.SetToken(clf.AuthToken)
}
if clf.TokenSecret != "" {
// store the value of the --token-secret flag:
cfg.SetTokenSecret(clf.TokenSecret)
}
// Apply flags used for the node to validate the Auth Server.
if err = cfg.ApplyCAPins(clf.CAPins); err != nil {
return trace.Wrap(err)
@@ -3078,11 +3071,6 @@ func ConfigureOpenSSH(clf *CommandLineFlags, cfg *servicecfg.Config) error {
cfg.SetToken(clf.AuthToken)
}
if clf.TokenSecret != "" {
// store the value of the --token-secret flag:
cfg.SetTokenSecret(clf.TokenSecret)
}
// apply --skip-version-check flag.
if clf.SkipVersionCheck {
cfg.SkipVersionCheck = clf.SkipVersionCheck
@@ -3284,7 +3272,6 @@ func applyTokenConfig(fc *FileConfig, cfg *servicecfg.Config) error {
if fc.JoinParams != (JoinParams{}) {
cfg.SetToken(fc.JoinParams.TokenName)
cfg.SetTokenSecret(fc.JoinParams.TokenSecret)
if err := types.ValidateJoinMethod(fc.JoinParams.Method); err != nil {
return trace.Wrap(err)
-1
View File
@@ -510,7 +510,6 @@ func (conf *FileConfig) CheckAndSetDefaults() error {
// JoinParams configures the parameters for Simplified Node Joining.
type JoinParams struct {
TokenName string `yaml:"token_name"`
TokenSecret string `yaml:"token_secret,omitempty"`
Method types.JoinMethod `yaml:"method"`
Azure AzureJoinParams `yaml:"azure,omitempty"`
BoundKeypair BoundKeypairParams `yaml:"bound_keypair,omitempty"`
+27
View File
@@ -19,6 +19,7 @@ package joining
import (
"cmp"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"net/url"
@@ -402,6 +403,8 @@ func StrongValidateToken(token *joiningv1.ScopedToken) error {
}
if name := token.GetMetadata().GetName(); name == "" {
return trace.BadParameter("missing name")
} else if strings.Contains(name, ":") {
return trace.BadParameter("scoped token names cannot contain colons")
}
if token.GetScope() == "" {
@@ -976,3 +979,27 @@ func VerifyImmutableLabelsHash(labels *joiningv1.ImmutableLabels, hash string) b
newHash := HashImmutableLabels(labels)
return newHash == hash
}
const tokenNameAndSecretSeparator = ":"
// EncodeScopedToken combines a token name and secret into a single encoded value. The encoded
// tokens are used when providing tokens externally to end users to ease UX.
func EncodeScopedToken(name, secret string) string {
return name + tokenNameAndSecretSeparator + base64.RawURLEncoding.EncodeToString([]byte(secret))
}
// DecodeScopedToken produces a token name and secret from an encoded value. If the token
// is not encoded the return value is token, "", false.
func DecodeScopedToken(token string) (name string, secret string, ok bool) {
name, base64Secret, ok := strings.Cut(token, tokenNameAndSecretSeparator)
if !ok {
return token, "", false
}
s, err := base64.RawURLEncoding.DecodeString(base64Secret)
if err != nil {
return token, "", false
}
return name, string(s), true
}
+24
View File
@@ -18,6 +18,7 @@ package joining_test
import (
"cmp"
"encoding/base64"
"fmt"
"maps"
"testing"
@@ -1040,6 +1041,14 @@ func TestValidateScopedToken(t *testing.T) {
},
expectedStrongErr: "scoped bot tokens do not support the `token` join method",
},
{
name: "tokens with semicolons are prevented",
baseToken: baseToken,
modFn: func(st *joiningv1.ScopedToken) {
st.GetMetadata().SetName("testing:testing")
},
expectedStrongErr: "scoped token names cannot contain colons",
},
}
for _, c := range cases {
@@ -1474,3 +1483,18 @@ func TestValidateTokenForUse(t *testing.T) {
assert.Error(t, strongValidateErr)
assert.ErrorIs(t, strongValidateErr, joining.ValidateTokenForUse(token))
}
func TestScopedTokenEncoding(t *testing.T) {
encoded := joining.EncodeScopedToken("TESTING", "SECRETSHERE")
require.Equal(t, "TESTING:"+base64.RawURLEncoding.EncodeToString([]byte("SECRETSHERE")), encoded)
name, secret, ok := joining.DecodeScopedToken(encoded)
assert.True(t, ok)
assert.Equal(t, "TESTING", name)
assert.Equal(t, "SECRETSHERE", secret)
name, secret, ok = joining.DecodeScopedToken("TESTING")
assert.False(t, ok)
assert.Equal(t, "TESTING", name)
assert.Empty(t, secret)
}
+7 -4
View File
@@ -59,6 +59,7 @@ import (
grpcmetrics "github.com/gravitational/teleport/lib/observability/metrics/grpc"
"github.com/gravitational/teleport/lib/openssh"
"github.com/gravitational/teleport/lib/reversetunnelclient"
"github.com/gravitational/teleport/lib/scopes/joining"
servicebreaker "github.com/gravitational/teleport/lib/service/breaker"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/utils"
@@ -788,14 +789,16 @@ func (process *TeleportProcess) makeJoinParams(
if err != nil {
return nil, trace.Wrap(err)
}
tokenSecret, err := process.Config.TokenSecret()
if err != nil {
return nil, trace.Wrap(err)
tokenName, tokenSecret := token, ""
if name, secret, ok := joining.DecodeScopedToken(token); ok {
tokenName = name
tokenSecret = secret
}
dataDir := cmp.Or(process.Config.DataDir, defaults.DataDir)
joinParams := &joinclient.JoinParams{
Token: token,
Token: tokenName,
TokenSecret: tokenSecret,
ID: id,
AuthServers: process.Config.AuthServerAddresses(),
-28
View File
@@ -290,11 +290,6 @@ type Config struct {
// using Token()
token string
// tokenSecret is either the secret needed to join with the token defined for the config, or
// a path that contains the secret. This is private to avoid external packages reading the
// value - the value should be obtained using TokenSecret()
tokenSecret string
// v1, v2 -
// AuthServers is a list of auth servers, proxies and peer auth servers to
// connect to. Yes, this is not just auth servers, the field name is
@@ -634,21 +629,6 @@ func (cfg *Config) Token() (string, error) {
return token, nil
}
// TokenSecret returns token secret needed to join the auth server with the configured token
//
// If the value stored points to a file, it will attempt to read the token secret from the file
// and return an error if it wasn't successful.
// If the value stored doesn't point to a file, it'll return the value stored.
// If the secret hasn't been set, an empty string will be returned
func (cfg *Config) TokenSecret() (string, error) {
secret, err := utils.TryReadValueAsFile(cfg.tokenSecret)
if err != nil {
return "", trace.Wrap(err)
}
return secret, nil
}
// SetToken stores the value for --token or auth_token in the config
//
// This can be either the token or an absolute path to a file containing the token.
@@ -656,14 +636,6 @@ func (cfg *Config) SetToken(token string) {
cfg.token = token
}
// SetTokenSecret stores the value for --token-secret or join_params.token_secret in the
// config.
//
// This can be either the secret or an absolute path to a file containing the secret.
func (cfg *Config) SetTokenSecret(secret string) {
cfg.tokenSecret = secret
}
// HasToken gives the ability to check if there has been a token value stored
// in the config
func (cfg *Config) HasToken() bool {
+2 -4
View File
@@ -1473,8 +1473,7 @@ func TestScopedBotSSH(t *testing.T) {
nodeCfg.ScopesFeatures = scopes.Features{Enabled: true}
nodeCfg.Hostname = nodeHostname
nodeCfg.DataDir = t.TempDir()
nodeCfg.SetToken(nodeTokenResp.GetToken().GetMetadata().GetName())
nodeCfg.SetTokenSecret(nodeTokenResp.GetToken().GetStatus().GetSecret())
nodeCfg.SetToken(jointoken.EncodeScopedToken(nodeTokenResp.GetToken().GetMetadata().GetName(), nodeTokenResp.GetToken().GetStatus().GetSecret()))
nodeCfg.SetAuthServerAddress(process.Config.Auth.ListenAddr)
nodeCfg.Auth.Enabled = false
nodeCfg.Proxy.Enabled = false
@@ -1743,8 +1742,7 @@ func TestScopedBotKubernetes(t *testing.T) {
kubeNodeCfg := servicecfg.MakeDefaultConfig()
kubeNodeCfg.ScopesFeatures = scopes.Features{Enabled: true}
kubeNodeCfg.DataDir = t.TempDir()
kubeNodeCfg.SetToken(kubeTokenResp.GetToken().GetMetadata().GetName())
kubeNodeCfg.SetTokenSecret(kubeTokenResp.GetToken().GetStatus().GetSecret())
kubeNodeCfg.SetToken(jointoken.EncodeScopedToken(kubeTokenResp.GetToken().GetMetadata().GetName(), kubeTokenResp.GetToken().GetStatus().GetSecret()))
kubeNodeCfg.SetAuthServerAddress(process.Config.Auth.ListenAddr)
kubeNodeCfg.Auth.Enabled = false
kubeNodeCfg.Proxy.Enabled = false
+7 -1
View File
@@ -42,6 +42,7 @@ import (
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/httplib"
"github.com/gravitational/teleport/lib/itertools/stream"
"github.com/gravitational/teleport/lib/scopes/joining"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/ui"
@@ -645,9 +646,14 @@ func (h *Handler) getJoinScript(ctx context.Context, settings scriptSettings) (s
return "", trace.Wrap(err, "Building install script options")
}
tokenName := token.GetName()
if secret, ok := token.GetSecret(); ok {
tokenName = joining.EncodeScopedToken(tokenName, secret)
}
nodeInstallOpts := scripts.InstallNodeScriptOptions{
InstallOptions: installOpts,
Token: token.GetName(),
Token: tokenName,
CAPins: caPins,
// We are using the joinMethod from the script settings instead of the one from the token
// to reproduce the previous script behavior. I'm also afraid that using the
+12
View File
@@ -3392,6 +3392,18 @@ func TestParseScopedRef(t *testing.T) {
id: "/staging::my role",
wantErr: true,
},
{
name: "scoped token with only name",
ref: "scoped_token",
id: "/test::test-token",
want: ScopedRef{Kind: "scoped_token", Scope: "/test", Name: "test-token"},
},
{
name: "scoped token with name and secret",
ref: "scoped_token",
id: "/test::test-token:YzZlYzdlYTg1YTZjZTEwZjVjYjAxMTc3Mjc2NTk3NGY",
want: ScopedRef{Kind: "scoped_token", Scope: "/test", Name: "test-token"},
},
}
for _, tt := range tests {
+9 -7
View File
@@ -20,7 +20,6 @@ import (
"context"
"fmt"
"io"
"slices"
"strings"
"time"
@@ -33,6 +32,7 @@ import (
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/itertools/stream"
"github.com/gravitational/teleport/lib/scopes"
"github.com/gravitational/teleport/lib/scopes/joining"
"github.com/gravitational/teleport/lib/services"
)
@@ -191,14 +191,12 @@ func deleteScopedToken(ctx context.Context, client *authclient.Client, subKind s
func ScopedTokenTextHelper(tokens []*joiningv1.ScopedToken, withSecrets bool) *bytes.Buffer {
headers := []string{
"ID",
"Token",
"Type",
"Assigns Scope",
"Labels",
"Expiry Time (UTC)",
}
if withSecrets {
headers = slices.Insert(headers, 1, "Secret")
}
table := asciitable.MakeTable(headers)
now := time.Now()
@@ -210,16 +208,20 @@ func ScopedTokenTextHelper(tokens []*joiningv1.ScopedToken, withSecrets bool) *b
expdur := expiresAt.Sub(now).Round(time.Second)
expiry = fmt.Sprintf("%s (%s)", exptime, expdur.String())
}
token := t.GetMetadata().GetName() + ":*****"
if withSecrets {
token = joining.EncodeScopedToken(t.GetMetadata().GetName(), t.GetStatus().GetSecret())
}
row := []string{
scopes.QualifiedName{Scope: t.GetScope(), Name: t.GetMetadata().GetName()}.String(),
token,
strings.Join(t.GetSpec().GetRoles(), ","),
t.GetSpec().GetAssignedScope(),
PrintMetadataLabels(t.GetMetadata().GetLabels()),
expiry,
}
if withSecrets {
row = slices.Insert(row, 1, t.GetStatus().GetSecret())
}
table.AddRow(row)
}
return table.AsBuffer()
+13 -3
View File
@@ -23,6 +23,7 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/scopes"
"github.com/gravitational/teleport/lib/services"
)
@@ -106,13 +107,22 @@ func ParseScopedRef(ref, id string) (ScopedRef, error) {
// is actually a subkind in the new format.
subKind := r.Name
if strings.Contains(id, scopes.QualifiedNameSeparator) {
if err := scopes.StrongValidateQualifiedName(id); err != nil {
return ScopedRef{}, trace.Wrap(err)
}
qn, err := scopes.ParseQualifiedName(id)
if err != nil {
return ScopedRef{}, trace.Wrap(err)
}
// A user may provide the token name as either <token_name> OR <token_name>:<encoded_secret>.
// Both formats are supported to improve UX, however, only the token name is consumed
// for tctl commands to operate properly. Strip the secret after parsing the SQN so
// that the colon in the SQN separator is not mistaken for the token/secret separator.
if r.Kind == types.KindScopedToken {
qn.Name, _, _ = strings.Cut(qn.Name, ":")
}
if err := qn.StrongValidate(); err != nil {
return ScopedRef{}, trace.Wrap(err)
}
return ScopedRef{Kind: r.Kind, SubKind: subKind, Scope: qn.Scope, Name: qn.Name}, nil
}
return ScopedRef{Kind: r.Kind, SubKind: subKind, Name: id}, nil
+8 -11
View File
@@ -225,15 +225,13 @@ func (c *ScopedTokensCommand) Add(ctx context.Context, client *authclient.Client
return trace.Wrap(err, "creating scoped token")
}
tokenName = tok.GetMetadata().GetName()
tokenSecret := tok.GetStatus().GetSecret()
token := joining.EncodeScopedToken(tok.GetMetadata().GetName(), tok.GetStatus().GetSecret())
// 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,
"token_secret": tokenSecret,
"token": token,
"roles": roles,
"scope": tok.GetScope(),
"assign_scope": tok.GetSpec().GetAssignedScope(),
@@ -256,17 +254,16 @@ func (c *ScopedTokensCommand) Add(ctx context.Context, client *authclient.Client
return nil
case teleport.Text:
fmt.Fprintln(c.Stdout, tokenName)
fmt.Fprintln(c.Stdout, token)
return nil
}
return trace.Wrap(showJoinInstructions(ctx, joinInstructionsInput{
out: c.Stdout,
ttl: c.ttl,
roles: roles,
tokenName: tokenName,
tokenSecret: tokenSecret,
client: client,
out: c.Stdout,
ttl: c.ttl,
roles: roles,
token: token,
client: client,
}))
}
+19 -21
View File
@@ -320,7 +320,7 @@ func (c *TokensCommand) Add(ctx context.Context, client *authclient.Client) erro
out: c.Stdout,
client: client,
roles: roles,
tokenName: token,
token: token,
ttl: c.ttl,
appName: c.appName,
appURI: c.appURI,
@@ -903,18 +903,17 @@ func generateAgentValues(params valueGeneratorParams) ([]byte, error) {
}
type joinInstructionsInput struct {
client *authclient.Client
roles types.SystemRoles
out io.Writer
tokenName string
tokenSecret string
ttl time.Duration
appName string
appURI string
dbName string
dbURI string
dbProtocol string
caPins []string
client *authclient.Client
roles types.SystemRoles
out io.Writer
token string
ttl time.Duration
appName string
appURI string
dbName string
dbURI string
dbProtocol string
caPins []string
}
func showJoinInstructions(ctx context.Context, in joinInstructionsInput) error {
@@ -949,7 +948,7 @@ func showJoinInstructions(ctx context.Context, in joinInstructionsInput) error {
return kubeMessageTemplate.Execute(in.out,
map[string]any{
"proxy_server": proxies[0].GetPublicAddr(),
"token": in.tokenName,
"token": in.token,
"minutes": in.ttl.Minutes(),
"set_roles": setRoles,
"version": proxies[0].GetTeleportVersion(),
@@ -969,7 +968,7 @@ func showJoinInstructions(ctx context.Context, in joinInstructionsInput) error {
return appMessageTemplate.Execute(in.out,
map[string]any{
"token": in.tokenName,
"token": in.token,
"minutes": in.ttl.Minutes(),
"ca_pins": in.caPins,
"proxy_server": proxies[0].GetPublicAddr(),
@@ -990,7 +989,7 @@ func showJoinInstructions(ctx context.Context, in joinInstructionsInput) error {
}
return dbMessageTemplate.Execute(in.out,
map[string]any{
"token": in.tokenName,
"token": in.token,
"minutes": in.ttl.Minutes(),
"ca_pins": in.caPins,
"proxy_server": proxies[0].GetPublicAddr(),
@@ -1000,24 +999,23 @@ func showJoinInstructions(ctx context.Context, in joinInstructionsInput) error {
})
case in.roles.Include(types.RoleTrustedCluster):
fmt.Fprintf(in.out, trustedClusterMessage,
in.tokenName,
in.token,
int(in.ttl.Minutes()))
case in.roles.Include(types.RoleWindowsDesktop):
return desktopMessageTemplate.Execute(in.out,
map[string]any{
"token": in.tokenName,
"token": in.token,
"minutes": in.ttl.Minutes(),
})
case in.roles.Include(types.RoleMDM):
return mdmTokenAddTemplate.Execute(in.out, map[string]any{
"token": in.tokenName,
"token": in.token,
"minutes": in.ttl.Minutes(),
"ca_pins": in.caPins,
})
default:
return nodeMessageTemplate.Execute(in.out, map[string]any{
"token": in.tokenName,
"secret": in.tokenSecret,
"token": in.token,
"roles": strings.ToLower(in.roles.String()),
"minutes": int(in.ttl.Minutes()),
"ca_pins": in.caPins,
-3
View File
@@ -148,8 +148,6 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
start.Flag("token",
"Invitation token or path to file with token value. Used to register with an auth server [none]").
StringVar(&ccf.AuthToken)
start.Flag("token-secret", "Invitation token secret or path to file with secret value. Used to register with an auth server [none]").
StringVar(&ccf.TokenSecret)
start.Flag("ca-pin",
"CA pin to validate the Auth Server (can be repeated for multiple pins)").
StringsVar(&ccf.CAPins)
@@ -496,7 +494,6 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con
joinOpenSSH.Flag("proxy-server", "Address of the proxy server.").StringVar(&ccf.ProxyServer)
joinOpenSSH.Flag("token", "Invitation token or path to file with token value to register with an auth server.").StringVar(&ccf.AuthToken)
joinOpenSSH.Flag("join-method", "Method to use to join the cluster.").EnumVar(&ccf.JoinMethod, "token", "iam", "ec2")
joinOpenSSH.Flag("token-secret", "Invitation token secret or path to file with secret value. Used to register with an auth server [none]").StringVar(&ccf.TokenSecret)
joinOpenSSH.Flag("openssh-config", fmt.Sprintf("Path to the OpenSSH config file [%v].", "/etc/ssh/sshd_config")).Default("/etc/ssh/sshd_config").StringVar(&ccf.OpenSSHConfigPath)
joinOpenSSH.Flag("data-dir", fmt.Sprintf("Path to directory to store teleport data [%v].", defaults.DataDir)).Default(defaults.DataDir).StringVar(&ccf.DataDir)
joinOpenSSH.Flag("restart-sshd", "Restart OpenSSH.").Default("true").BoolVar(&ccf.RestartOpenSSH)