diff --git a/lib/tbot/cli/start_workload_identity_jwt.go b/lib/tbot/cli/start_workload_identity_jwt.go
new file mode 100644
index 00000000000..9e8a318a23d
--- /dev/null
+++ b/lib/tbot/cli/start_workload_identity_jwt.go
@@ -0,0 +1,113 @@
+// 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 cli
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/client"
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// WorkloadIdentityJWTCommand implements `tbot start workload-identity-jwt` and
+// `tbot configure workload-identity-jwt`.
+type WorkloadIdentityJWTCommand struct {
+ *sharedStartArgs
+ *sharedDestinationArgs
+ *genericMutatorHandler
+
+ // NameSelector is the name of the workload identity to use.
+ // --workload-identity-name foo
+ NameSelector string
+ // LabelSelector is the labels of the workload identity to use.
+ // --workload-identity-labels x=y,z=a
+ LabelSelector string
+
+ // Audiences is the list of audiences to include in the JWT.
+ Audiences []string
+}
+
+// NewWorkloadIdentityJWTCommand initializes the command and flags for the
+// `workload-identity-jwt` output and returns a struct that will contain the parse
+// result.
+func NewWorkloadIdentityJWTCommand(parentCmd *kingpin.CmdClause, action MutatorAction, mode CommandMode) *WorkloadIdentityJWTCommand {
+ // TODO(noah): Unhide this command when feature flag removed
+ cmd := parentCmd.Command("workload-identity-jwt", fmt.Sprintf("%s tbot with a SPIFFE-compatible JWT SVID output.", mode)).Hidden()
+
+ c := &WorkloadIdentityJWTCommand{}
+ c.sharedStartArgs = newSharedStartArgs(cmd)
+ c.sharedDestinationArgs = newSharedDestinationArgs(cmd)
+ c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
+
+ cmd.Flag(
+ "name-selector",
+ "The name of the workload identity to issue",
+ ).StringVar(&c.NameSelector)
+ cmd.Flag(
+ "label-selector",
+ "A label-based selector for which workload identities to issue. Multiple labels can be provided using ','.",
+ ).StringVar(&c.LabelSelector)
+ cmd.Flag(
+ "audience",
+ "Specify the audiences to include in the JWT. At least one audience must be specified.",
+ ).Required().StringsVar(&c.Audiences)
+
+ return c
+}
+
+// ApplyConfig applies the parsed flags to the bot configuration.
+func (c *WorkloadIdentityJWTCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+
+ dest, err := c.BuildDestination()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ svc := &config.WorkloadIdentityJWTService{
+ Destination: dest,
+ Audiences: c.Audiences,
+ }
+
+ switch {
+ case c.NameSelector != "" && c.LabelSelector != "":
+ return trace.BadParameter("name-selector and label-selector flags are mutually exclusive")
+ case c.NameSelector != "":
+ svc.Selector.Name = c.NameSelector
+ case c.LabelSelector != "":
+ labels, err := client.ParseLabelSpec(c.LabelSelector)
+ if err != nil {
+ return trace.Wrap(err, "parsing --label-selector")
+ }
+ svc.Selector.Labels = map[string][]string{}
+ for k, v := range labels {
+ svc.Selector.Labels[k] = []string{v}
+ }
+ default:
+ return trace.BadParameter("name-selector or label-selector must be specified")
+ }
+
+ cfg.Services = append(cfg.Services, svc)
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_workload_identity_jwt_test.go b/lib/tbot/cli/start_workload_identity_jwt_test.go
new file mode 100644
index 00000000000..f00b1bff2af
--- /dev/null
+++ b/lib/tbot/cli/start_workload_identity_jwt_test.go
@@ -0,0 +1,90 @@
+// 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+func TestWorkloadIdentityJWTCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewWorkloadIdentityJWTCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "workload-identity-jwt",
+ "--destination=/bar",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--label-selector=*=*,foo=bar",
+ "--audience=foo",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ svc := cfg.Services[0]
+ wis, ok := svc.(*config.WorkloadIdentityJWTService)
+ require.True(t, ok)
+
+ dir, ok := wis.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/bar", dir.Path)
+
+ require.Equal(t, map[string][]string{
+ "*": {"*"},
+ "foo": {"bar"},
+ }, wis.Selector.Labels)
+
+ require.Equal(t, []string{"foo"}, wis.Audiences)
+ },
+ },
+ {
+ name: "success name selector",
+ args: []string{
+ "start",
+ "workload-identity-jwt",
+ "--destination=/bar",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--name-selector=jim",
+ "--audience=foo",
+ "--audience=bar",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ svc := cfg.Services[0]
+ wis, ok := svc.(*config.WorkloadIdentityJWTService)
+ require.True(t, ok)
+
+ dir, ok := wis.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/bar", dir.Path)
+
+ require.Equal(t, "jim", wis.Selector.Name)
+ require.Equal(t, []string{"foo", "bar"}, wis.Audiences)
+
+ },
+ },
+ })
+}
diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go
index b23101fc4bc..79eed0db680 100644
--- a/lib/tbot/config/config.go
+++ b/lib/tbot/config/config.go
@@ -411,6 +411,12 @@ func (o *ServiceConfigs) UnmarshalYAML(node *yaml.Node) error {
return trace.Wrap(err)
}
out = append(out, v)
+ case WorkloadIdentityJWTOutputType:
+ v := &WorkloadIdentityJWTService{}
+ if err := node.Decode(v); err != nil {
+ return trace.Wrap(err)
+ }
+ out = append(out, v)
default:
return trace.BadParameter("unrecognized service type (%s)", header.Type)
}
diff --git a/lib/tbot/config/config_test.go b/lib/tbot/config/config_test.go
index d19abe08599..455f9233e6b 100644
--- a/lib/tbot/config/config_test.go
+++ b/lib/tbot/config/config_test.go
@@ -278,6 +278,15 @@ func TestBotConfig_YAML(t *testing.T) {
Name: "my-workload-identity",
},
},
+ &WorkloadIdentityJWTService{
+ Destination: &DestinationDirectory{
+ Path: "/an/output/path",
+ },
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Audiences: []string{"audience1", "audience2"},
+ },
},
},
},
diff --git a/lib/tbot/config/service_workload_identity_jwt.go b/lib/tbot/config/service_workload_identity_jwt.go
new file mode 100644
index 00000000000..4c71dace4bc
--- /dev/null
+++ b/lib/tbot/config/service_workload_identity_jwt.go
@@ -0,0 +1,106 @@
+// 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 config
+
+import (
+ "context"
+
+ "github.com/gravitational/trace"
+ "gopkg.in/yaml.v3"
+
+ "github.com/gravitational/teleport/lib/tbot/bot"
+)
+
+const WorkloadIdentityJWTOutputType = "workload-identity-jwt"
+
+var (
+ _ ServiceConfig = &WorkloadIdentityJWTService{}
+ _ Initable = &WorkloadIdentityJWTService{}
+)
+
+// WorkloadIdentityJWTService is the configuration for the WorkloadIdentityJWTService
+type WorkloadIdentityJWTService struct {
+ // Selector is the selector for the WorkloadIdentity resource that will be
+ // used to issue WICs.
+ Selector WorkloadIdentitySelector `yaml:"selector"`
+ // Destination is where the credentials should be written to.
+ Destination bot.Destination `yaml:"destination"`
+ // Audiences is the list of audiences that the JWT should be valid for.
+ Audiences []string
+}
+
+// Init initializes the destination.
+func (o *WorkloadIdentityJWTService) Init(ctx context.Context) error {
+ return trace.Wrap(o.Destination.Init(ctx, []string{}))
+}
+
+// CheckAndSetDefaults checks the WorkloadIdentityJWTService values and sets any defaults.
+func (o *WorkloadIdentityJWTService) CheckAndSetDefaults() error {
+ if err := validateOutputDestination(o.Destination); err != nil {
+ return trace.Wrap(err)
+ }
+ if err := o.Selector.CheckAndSetDefaults(); err != nil {
+ return trace.Wrap(err, "validating selector")
+ }
+ if len(o.Audiences) == 0 {
+ return trace.BadParameter("audiences: must have at least one value")
+ }
+ return nil
+}
+
+// JWTSVIDPath is the name of the artifact that a JWT SVID will be written to.
+const JWTSVIDPath = "jwt_svid"
+
+// Describe returns the file descriptions for the WorkloadIdentityJWTService.
+func (o *WorkloadIdentityJWTService) Describe() []FileDescription {
+ fds := []FileDescription{
+ {
+ Name: JWTSVIDPath,
+ },
+ }
+ return fds
+}
+
+func (o *WorkloadIdentityJWTService) Type() string {
+ return WorkloadIdentityJWTOutputType
+}
+
+// MarshalYAML marshals the WorkloadIdentityJWTService into YAML.
+func (o *WorkloadIdentityJWTService) MarshalYAML() (interface{}, error) {
+ type raw WorkloadIdentityJWTService
+ return withTypeHeader((*raw)(o), WorkloadIdentityJWTOutputType)
+}
+
+// UnmarshalYAML unmarshals the WorkloadIdentityJWTService from YAML.
+func (o *WorkloadIdentityJWTService) UnmarshalYAML(node *yaml.Node) error {
+ dest, err := extractOutputDestination(node)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ // Alias type to remove UnmarshalYAML to avoid recursion
+ type raw WorkloadIdentityJWTService
+ if err := node.Decode((*raw)(o)); err != nil {
+ return trace.Wrap(err)
+ }
+ o.Destination = dest
+ return nil
+}
+
+// GetDestination returns the destination.
+func (o *WorkloadIdentityJWTService) GetDestination() bot.Destination {
+ return o.Destination
+}
diff --git a/lib/tbot/config/service_workload_identity_jwt_test.go b/lib/tbot/config/service_workload_identity_jwt_test.go
new file mode 100644
index 00000000000..6f4f2571df0
--- /dev/null
+++ b/lib/tbot/config/service_workload_identity_jwt_test.go
@@ -0,0 +1,148 @@
+// 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 config
+
+import (
+ "testing"
+
+ "github.com/gravitational/teleport/lib/tbot/botfs"
+)
+
+func TestWorkloadIdentityJWTService_YAML(t *testing.T) {
+ t.Parallel()
+
+ dest := &DestinationMemory{}
+ tests := []testYAMLCase[WorkloadIdentityJWTService]{
+ {
+ name: "full",
+ in: WorkloadIdentityJWTService{
+ Destination: dest,
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Audiences: []string{"audience1", "audience2"},
+ },
+ },
+ }
+ testYAML(t, tests)
+}
+
+func TestWorkloadIdentityJWTService_CheckAndSetDefaults(t *testing.T) {
+ t.Parallel()
+
+ tests := []testCheckAndSetDefaultsCase[*WorkloadIdentityJWTService]{
+ {
+ name: "valid",
+ in: func() *WorkloadIdentityJWTService {
+ return &WorkloadIdentityJWTService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ Audiences: []string{"audience1", "audience2"},
+ }
+ },
+ },
+ {
+ name: "valid with labels",
+ in: func() *WorkloadIdentityJWTService {
+ return &WorkloadIdentityJWTService{
+ Selector: WorkloadIdentitySelector{
+ Labels: map[string][]string{
+ "key": {"value"},
+ },
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ Audiences: []string{"audience1", "audience2"},
+ }
+ },
+ },
+ {
+ name: "missing audience",
+ in: func() *WorkloadIdentityJWTService {
+ return &WorkloadIdentityJWTService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ }
+ },
+ wantErr: "audiences: must have at least one value",
+ },
+ {
+ name: "missing selectors",
+ in: func() *WorkloadIdentityJWTService {
+ return &WorkloadIdentityJWTService{
+ Selector: WorkloadIdentitySelector{},
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ Audiences: []string{"audience1", "audience2"},
+ }
+ },
+ wantErr: "one of ['name', 'labels'] must be set",
+ },
+ {
+ name: "too many selectors",
+ in: func() *WorkloadIdentityJWTService {
+ return &WorkloadIdentityJWTService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ Labels: map[string][]string{
+ "key": {"value"},
+ },
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ Audiences: []string{"audience1", "audience2"},
+ }
+ },
+ wantErr: "at most one of ['name', 'labels'] can be set",
+ },
+ {
+ name: "missing destination",
+ in: func() *WorkloadIdentityJWTService {
+ return &WorkloadIdentityJWTService{
+ Destination: nil,
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Audiences: []string{"audience1", "audience2"},
+ }
+ },
+ wantErr: "no destination configured for output",
+ },
+ }
+ testCheckAndSetDefaults(t, tests)
+}
diff --git a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden
index 302209284ad..79bfdb3b422 100644
--- a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden
+++ b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden
@@ -68,6 +68,15 @@ services:
enabled: false
selector:
name: my-workload-identity
+ - type: workload-identity-jwt
+ selector:
+ name: my-workload-identity
+ destination:
+ type: directory
+ path: /an/output/path
+ audiences:
+ - audience1
+ - audience2
debug: true
auth_server: example.teleport.sh:443
certificate_ttl: 1m0s
diff --git a/lib/tbot/config/testdata/TestWorkloadIdentityJWTService_YAML/full.golden b/lib/tbot/config/testdata/TestWorkloadIdentityJWTService_YAML/full.golden
new file mode 100644
index 00000000000..09c561e4cde
--- /dev/null
+++ b/lib/tbot/config/testdata/TestWorkloadIdentityJWTService_YAML/full.golden
@@ -0,0 +1,8 @@
+type: workload-identity-jwt
+selector:
+ name: my-workload-identity
+destination:
+ type: memory
+audiences:
+ - audience1
+ - audience2
diff --git a/lib/tbot/service_workload_identity_jwt.go b/lib/tbot/service_workload_identity_jwt.go
new file mode 100644
index 00000000000..0f3093ea8ca
--- /dev/null
+++ b/lib/tbot/service_workload_identity_jwt.go
@@ -0,0 +1,250 @@
+// 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 tbot
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "math"
+ "time"
+
+ "github.com/gravitational/trace"
+
+ workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
+ "github.com/gravitational/teleport/api/utils/retryutils"
+ "github.com/gravitational/teleport/lib/auth/authclient"
+ "github.com/gravitational/teleport/lib/reversetunnelclient"
+ "github.com/gravitational/teleport/lib/tbot/config"
+ "github.com/gravitational/teleport/lib/tbot/identity"
+ "github.com/gravitational/teleport/lib/tbot/workloadidentity"
+)
+
+// WorkloadIdentityJWTService is a service that retrieves JWT workload identity
+// credentials for WorkloadIdentity resources.
+type WorkloadIdentityJWTService struct {
+ botAuthClient *authclient.Client
+ botCfg *config.BotConfig
+ cfg *config.WorkloadIdentityJWTService
+ getBotIdentity getBotIdentityFn
+ log *slog.Logger
+ resolver reversetunnelclient.Resolver
+ // trustBundleCache is the cache of trust bundles. It only needs to be
+ // provided when running in daemon mode.
+ trustBundleCache *workloadidentity.TrustBundleCache
+}
+
+// String returns a human-readable description of the service.
+func (s *WorkloadIdentityJWTService) String() string {
+ return fmt.Sprintf("workload-identity-jwt (%s)", s.cfg.Destination.String())
+}
+
+// OneShot runs the service once, generating the output and writing it to the
+// destination, before exiting.
+func (s *WorkloadIdentityJWTService) OneShot(ctx context.Context) error {
+ res, err := s.requestJWTSVID(ctx)
+ if err != nil {
+ return trace.Wrap(err, "requesting JWT SVID")
+ }
+ return s.render(ctx, res)
+}
+
+// Run runs the service in daemon mode, periodically generating the output and
+// writing it to the destination.
+func (s *WorkloadIdentityJWTService) Run(ctx context.Context) error {
+ bundleSet, err := s.trustBundleCache.GetBundleSet(ctx)
+ if err != nil {
+ return trace.Wrap(err, "getting trust bundle set")
+ }
+
+ jitter := retryutils.DefaultJitter
+ var cred *workloadidentityv1pb.Credential
+ var failures int
+ firstRun := make(chan struct{}, 1)
+ firstRun <- struct{}{}
+ for {
+ var retryAfter <-chan time.Time
+ if failures > 0 {
+ backoffTime := time.Second * time.Duration(math.Pow(2, float64(failures-1)))
+ if backoffTime > time.Minute {
+ backoffTime = time.Minute
+ }
+ backoffTime = jitter(backoffTime)
+ s.log.WarnContext(
+ ctx,
+ "Last attempt to generate output failed, will retry",
+ "retry_after", backoffTime,
+ "failures", failures,
+ )
+ retryAfter = time.After(time.Duration(failures) * time.Second)
+ }
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-retryAfter:
+ s.log.InfoContext(ctx, "Retrying")
+ case <-bundleSet.Stale():
+ // We don't actually write this bundle out at the moment, but, we
+ // still track it so we know when to reissue the JWT SVID.
+ newBundleSet, err := s.trustBundleCache.GetBundleSet(ctx)
+ if err != nil {
+ return trace.Wrap(err, "getting trust bundle set")
+ }
+ s.log.InfoContext(ctx, "Trust bundle set has been updated")
+ if !newBundleSet.Local.Equal(bundleSet.Local) {
+ // If the local trust domain CA has changed, we need to reissue
+ // the SVID.
+ cred = nil
+ }
+ bundleSet = newBundleSet
+ case <-time.After(s.botCfg.RenewalInterval):
+ s.log.InfoContext(ctx, "Renewal interval reached, renewing SVIDs")
+ cred = nil
+ case <-firstRun:
+ }
+
+ if cred == nil {
+ var err error
+ cred, err = s.requestJWTSVID(ctx)
+ if err != nil {
+ s.log.ErrorContext(ctx, "Failed to request JWT SVID", "error", err)
+ failures++
+ continue
+ }
+ }
+ if err := s.render(ctx, cred); err != nil {
+ s.log.ErrorContext(ctx, "Failed to render output", "error", err)
+ failures++
+ continue
+ }
+ failures = 0
+ }
+}
+
+func (s *WorkloadIdentityJWTService) requestJWTSVID(
+ ctx context.Context,
+) (
+ *workloadidentityv1pb.Credential,
+ error,
+) {
+ ctx, span := tracer.Start(
+ ctx,
+ "WorkloadIdentityJWTService/requestJWTSVID",
+ )
+ defer span.End()
+
+ roles, err := fetchDefaultRoles(ctx, s.botAuthClient, s.getBotIdentity())
+ if err != nil {
+ return nil, trace.Wrap(err, "fetching roles")
+ }
+
+ id, err := generateIdentity(
+ ctx,
+ s.botAuthClient,
+ s.getBotIdentity(),
+ roles,
+ s.botCfg.CertificateTTL,
+ nil,
+ )
+ if err != nil {
+ return nil, trace.Wrap(err, "generating identity")
+ }
+ // create a client that uses the impersonated identity, so that when we
+ // fetch information, we can ensure access rights are enforced.
+ facade := identity.NewFacade(s.botCfg.FIPS, s.botCfg.Insecure, id)
+ impersonatedClient, err := clientForFacade(ctx, s.log, s.botCfg, facade, s.resolver)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ defer impersonatedClient.Close()
+
+ credentials, err := workloadidentity.IssueJWTWorkloadIdentity(
+ ctx,
+ s.log,
+ impersonatedClient,
+ s.cfg.Selector,
+ s.cfg.Audiences,
+ s.botCfg.CertificateTTL,
+ nil,
+ )
+ if err != nil {
+ return nil, trace.Wrap(err, "generating JWT SVID")
+ }
+ var credential *workloadidentityv1pb.Credential
+ switch len(credentials) {
+ case 0:
+ return nil, trace.BadParameter("no JWT SVIDs returned")
+ case 1:
+ credential = credentials[0]
+ default:
+ // We could eventually implement some kind of hint selection mechanism
+ // to pick the "right" one.
+ received := make([]string, 0, len(credentials))
+ for _, cred := range credentials {
+ received = append(received,
+ fmt.Sprintf(
+ "%s:%s",
+ cred.WorkloadIdentityName,
+ cred.SpiffeId,
+ ),
+ )
+ }
+ return nil, trace.BadParameter(
+ "multiple JWT SVIDs received: %v", received,
+ )
+ }
+
+ return credential, nil
+}
+
+func (s *WorkloadIdentityJWTService) render(
+ ctx context.Context,
+ cred *workloadidentityv1pb.Credential,
+) error {
+ ctx, span := tracer.Start(
+ ctx,
+ "WorkloadIdentityJWTService/render",
+ )
+ defer span.End()
+ s.log.InfoContext(ctx, "Rendering output")
+
+ // Check the ACLs. We can't fix them, but we can warn if they're
+ // misconfigured. We'll need to precompute a list of keys to check.
+ // Note: This may only log a warning, depending on configuration.
+ if err := s.cfg.Destination.Verify(identity.ListKeys(identity.DestinationKinds()...)); err != nil {
+ return trace.Wrap(err)
+ }
+ // Ensure this destination is also writable. This is a hard fail if
+ // ACLs are misconfigured, regardless of configuration.
+ if err := identity.VerifyWrite(ctx, s.cfg.Destination); err != nil {
+ return trace.Wrap(err, "verifying destination")
+ }
+
+ if err := s.cfg.Destination.Write(
+ ctx, config.JWTSVIDPath, []byte(cred.GetJwtSvid().GetJwt()),
+ ); err != nil {
+ return trace.Wrap(err, "writing jwt svid")
+ }
+
+ s.log.InfoContext(
+ ctx,
+ "Successfully wrote JWT workload identity credential to destination",
+ "workload_identity", workloadidentity.WorkloadIdentityLogValue(cred),
+ "destination", s.cfg.Destination.String(),
+ )
+ return nil
+}
diff --git a/lib/tbot/service_workload_identity_jwt_test.go b/lib/tbot/service_workload_identity_jwt_test.go
new file mode 100644
index 00000000000..dacd495ee69
--- /dev/null
+++ b/lib/tbot/service_workload_identity_jwt_test.go
@@ -0,0 +1,152 @@
+// 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 tbot
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/spiffe/go-spiffe/v2/svid/jwtsvid"
+ "github.com/stretchr/testify/require"
+
+ headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
+ workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
+ "github.com/gravitational/teleport/api/types"
+ apiutils "github.com/gravitational/teleport/api/utils"
+ "github.com/gravitational/teleport/lib/auth/machineid/workloadidentityv1/experiment"
+ "github.com/gravitational/teleport/lib/tbot/config"
+ "github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/teleport/tool/teleport/testenv"
+)
+
+func TestBotWorkloadIdentityJWT(t *testing.T) {
+ experimentStatus := experiment.Enabled()
+ defer experiment.SetEnabled(experimentStatus)
+ experiment.SetEnabled(true)
+
+ ctx := context.Background()
+ log := utils.NewSlogLoggerForTests()
+
+ process := testenv.MakeTestServer(t, defaultTestServerOpts(t, log))
+ rootClient := testenv.MakeDefaultAuthClient(t, process)
+
+ role, err := types.NewRole("issue-foo", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ WorkloadIdentityLabels: map[string]apiutils.Strings{
+ "foo": []string{"bar"},
+ },
+ Rules: []types.Rule{
+ {
+ Resources: []string{types.KindWorkloadIdentity},
+ Verbs: []string{types.VerbRead, types.VerbList},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ role, err = rootClient.UpsertRole(ctx, role)
+ require.NoError(t, err)
+
+ workloadIdentity := &workloadidentityv1pb.WorkloadIdentity{
+ Kind: types.KindWorkloadIdentity,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: "foo-bar-bizz",
+ Labels: map[string]string{
+ "foo": "bar",
+ },
+ },
+ Spec: &workloadidentityv1pb.WorkloadIdentitySpec{
+ Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{
+ Id: "/valid/{{ user.bot_name }}",
+ },
+ },
+ }
+ workloadIdentity, err = rootClient.WorkloadIdentityResourceServiceClient().
+ CreateWorkloadIdentity(ctx, &workloadidentityv1pb.CreateWorkloadIdentityRequest{
+ WorkloadIdentity: workloadIdentity,
+ })
+ require.NoError(t, err)
+
+ t.Run("By Name", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ onboarding, _ := makeBot(t, rootClient, "by-name", role.GetName())
+ botConfig := defaultBotConfig(t, process, onboarding, config.ServiceConfigs{
+ &config.WorkloadIdentityJWTService{
+ Selector: config.WorkloadIdentitySelector{
+ Name: workloadIdentity.GetMetadata().GetName(),
+ },
+ Destination: &config.DestinationDirectory{
+ Path: tmpDir,
+ },
+ Audiences: []string{"example", "foo"},
+ },
+ }, defaultBotConfigOpts{
+ useAuthServer: true,
+ insecure: true,
+ })
+ botConfig.Oneshot = true
+ b := New(botConfig, log)
+ // Run Bot with 10 second timeout to catch hangs.
+ ctx, cancel := context.WithTimeout(ctx, time.Second*10)
+ defer cancel()
+ require.NoError(t, b.Run(ctx))
+
+ jwtBytes, err := os.ReadFile(filepath.Join(tmpDir, config.JWTSVIDPath))
+ require.NoError(t, err)
+ jwt, err := jwtsvid.ParseInsecure(string(jwtBytes), []string{"example"})
+ require.NoError(t, err)
+ require.Equal(t, "spiffe://root/valid/by-name", jwt.ID.String())
+ require.Equal(t, []string{"example", "foo"}, jwt.Audience)
+ })
+ t.Run("By Labels", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ onboarding, _ := makeBot(t, rootClient, "by-labels", role.GetName())
+ botConfig := defaultBotConfig(t, process, onboarding, config.ServiceConfigs{
+ &config.WorkloadIdentityJWTService{
+ Selector: config.WorkloadIdentitySelector{
+ Labels: map[string][]string{
+ "foo": {"bar"},
+ },
+ },
+ Destination: &config.DestinationDirectory{
+ Path: tmpDir,
+ },
+ Audiences: []string{"example"},
+ },
+ }, defaultBotConfigOpts{
+ useAuthServer: true,
+ insecure: true,
+ })
+ botConfig.Oneshot = true
+ b := New(botConfig, log)
+ // Run Bot with 10 second timeout to catch hangs.
+ ctx, cancel := context.WithTimeout(ctx, time.Second*10)
+ defer cancel()
+ require.NoError(t, b.Run(ctx))
+
+ jwtBytes, err := os.ReadFile(filepath.Join(tmpDir, config.JWTSVIDPath))
+ require.NoError(t, err)
+ jwt, err := jwtsvid.ParseInsecure(string(jwtBytes), []string{"example"})
+ require.NoError(t, err)
+ require.Equal(t, "spiffe://root/valid/by-labels", jwt.ID.String())
+ require.Equal(t, []string{"example"}, jwt.Audience)
+ })
+}
diff --git a/lib/tbot/service_workload_identity_x509_test.go b/lib/tbot/service_workload_identity_x509_test.go
index 392670f5ce6..81d0823cc7f 100644
--- a/lib/tbot/service_workload_identity_x509_test.go
+++ b/lib/tbot/service_workload_identity_x509_test.go
@@ -19,9 +19,12 @@ package tbot
import (
"context"
"path"
+ "path/filepath"
"testing"
"time"
+ "github.com/spiffe/go-spiffe/v2/bundle/x509bundle"
+ "github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/svid/x509svid"
"github.com/stretchr/testify/require"
@@ -113,6 +116,16 @@ func TestBotWorkloadIdentityX509(t *testing.T) {
)
require.NoError(t, err)
require.Equal(t, "spiffe://root/valid/by-name", svid.ID.String())
+
+ // Validate the trust bundle was written to disk, and, that our SVID
+ // appears valid according to the trust bundle.
+ td := spiffeid.RequireTrustDomainFromString("root")
+ bundle, err := x509bundle.Load(
+ td, filepath.Join(tmpDir, config.SVIDTrustBundlePEMPath),
+ )
+ require.NoError(t, err)
+ _, _, err = x509svid.Verify(svid.Certificates, bundle)
+ require.NoError(t, err)
})
t.Run("By Labels", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -145,5 +158,15 @@ func TestBotWorkloadIdentityX509(t *testing.T) {
)
require.NoError(t, err)
require.Equal(t, "spiffe://root/valid/by-labels", svid.ID.String())
+
+ // Validate the trust bundle was written to disk, and, that our SVID
+ // appears valid according to the trust bundle.
+ td := spiffeid.RequireTrustDomainFromString("root")
+ bundle, err := x509bundle.Load(
+ td, filepath.Join(tmpDir, config.SVIDTrustBundlePEMPath),
+ )
+ require.NoError(t, err)
+ _, _, err = x509svid.Verify(svid.Certificates, bundle)
+ require.NoError(t, err)
})
}
diff --git a/lib/tbot/tbot.go b/lib/tbot/tbot.go
index cf0e38b0b2e..6afcfabc13c 100644
--- a/lib/tbot/tbot.go
+++ b/lib/tbot/tbot.go
@@ -505,6 +505,25 @@ func (b *Bot) Run(ctx context.Context) (err error) {
svc.trustBundleCache = tbCache
}
services = append(services, svc)
+ case *config.WorkloadIdentityJWTService:
+ svc := &WorkloadIdentityJWTService{
+ botAuthClient: b.botIdentitySvc.GetClient(),
+ botCfg: b.cfg,
+ cfg: svcCfg,
+ getBotIdentity: b.botIdentitySvc.GetIdentity,
+ resolver: resolver,
+ }
+ svc.log = b.log.With(
+ teleport.ComponentKey, teleport.Component(componentTBot, "svc", svc.String()),
+ )
+ if !b.cfg.Oneshot {
+ tbCache, err := setupTrustBundleCache()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ svc.trustBundleCache = tbCache
+ }
+ services = append(services, svc)
case *config.WorkloadIdentityAPIService:
clientCredential := &config.UnstableClientCredentialOutput{}
svcIdentity := &ClientCredentialOutputService{
diff --git a/tool/tbot/main.go b/tool/tbot/main.go
index fac8d35747a..45278e193c5 100644
--- a/tool/tbot/main.go
+++ b/tool/tbot/main.go
@@ -146,6 +146,9 @@ func Run(args []string, stdout io.Writer) error {
cli.NewWorkloadIdentityAPICommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart),
cli.NewWorkloadIdentityAPICommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure),
+
+ cli.NewWorkloadIdentityJWTCommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart),
+ cli.NewWorkloadIdentityJWTCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure),
)
// Initialize legacy-style commands. These are simple enough to not really