diff --git a/go.mod b/go.mod
index d252395f334ce..13cfea23c3a88 100644
--- a/go.mod
+++ b/go.mod
@@ -192,6 +192,7 @@ require (
github.com/sijms/go-ora/v2 v2.8.24
github.com/snowflakedb/gosnowflake v1.13.0
github.com/spf13/cobra v1.9.1
+ github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8
github.com/spiffe/go-spiffe/v2 v2.5.0
github.com/stretchr/testify v1.10.0
github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb
@@ -296,6 +297,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 // indirect
+ github.com/aws/rolesanywhere-credential-helper v1.2.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
diff --git a/go.sum b/go.sum
index 87c760f17aec6..60bf6bbab20d0 100644
--- a/go.sum
+++ b/go.sum
@@ -963,6 +963,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 h1:BHEK2Q/7CMRMCb3nySi/w8UbIcP
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/aws-sigv4-auth-cassandra-gocql-driver-plugin v1.1.0 h1:EJsHUYgFBV7/N1YtL73lsfZODAOU+CnNSZfEAlqqQaA=
github.com/aws/aws-sigv4-auth-cassandra-gocql-driver-plugin v1.1.0/go.mod h1:AxKuXHc0zv2yYaeueUG7R3ONbcnQIuDj0bkdFmPVRzU=
+github.com/aws/rolesanywhere-credential-helper v1.2.0 h1:eLqJvSznH8nJk48dwFc0raWOpbTGgBeNYH3Q8UQFVx4=
+github.com/aws/rolesanywhere-credential-helper v1.2.0/go.mod h1:YRxmRrAaqbVVXPNH1gHT76nWaMGvpAziHAHw8UwKrpU=
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
@@ -2182,6 +2184,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
+github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8 h1:KFHLQuNqUG4YZXo+QwMurqFxU5qWchFDCxYbl+uJz9w=
+github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8/go.mod h1:xhhvkBenvvbuEEw9YR2HwGzUPv0sQiHd3wv8avhra+U=
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod
index 853b9e5eae99f..20b217aad7721 100644
--- a/integrations/terraform/go.mod
+++ b/integrations/terraform/go.mod
@@ -106,6 +106,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssoadmin v1.30.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 // indirect
+ github.com/aws/rolesanywhere-credential-helper v1.2.0 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/aws/smithy-go/tracing/smithyoteltracing v1.0.4 // indirect
github.com/beevik/etree v1.5.0 // indirect
@@ -317,6 +318,7 @@ require (
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
+ github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/thales-e-security/pool v0.0.2 // indirect
@@ -375,6 +377,7 @@ require (
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
helm.sh/helm/v3 v3.17.1 // indirect
diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum
index c24a583c10015..b263b56ac3807 100644
--- a/integrations/terraform/go.sum
+++ b/integrations/terraform/go.sum
@@ -306,6 +306,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 h1:BHEK2Q/7CMRMCb3nySi/w8UbIcP
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/aws-sigv4-auth-cassandra-gocql-driver-plugin v1.1.0 h1:EJsHUYgFBV7/N1YtL73lsfZODAOU+CnNSZfEAlqqQaA=
github.com/aws/aws-sigv4-auth-cassandra-gocql-driver-plugin v1.1.0/go.mod h1:AxKuXHc0zv2yYaeueUG7R3ONbcnQIuDj0bkdFmPVRzU=
+github.com/aws/rolesanywhere-credential-helper v1.2.0 h1:eLqJvSznH8nJk48dwFc0raWOpbTGgBeNYH3Q8UQFVx4=
+github.com/aws/rolesanywhere-credential-helper v1.2.0/go.mod h1:YRxmRrAaqbVVXPNH1gHT76nWaMGvpAziHAHw8UwKrpU=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go/tracing/smithyoteltracing v1.0.4 h1:Gx4ipHtKfaABSHAVo4Zjo2E4ClKzYqZ2NrPO0gy6qvY=
@@ -1158,6 +1160,8 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8 h1:KFHLQuNqUG4YZXo+QwMurqFxU5qWchFDCxYbl+uJz9w=
+github.com/spiffe/aws-spiffe-workload-helper v0.0.1-rc.8/go.mod h1:xhhvkBenvvbuEEw9YR2HwGzUPv0sQiHd3wv8avhra+U=
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/lib/tbot/cli/start_workload_identity_aws_ra.go b/lib/tbot/cli/start_workload_identity_aws_ra.go
new file mode 100644
index 0000000000000..db250831ccfb6
--- /dev/null
+++ b/lib/tbot/cli/start_workload_identity_aws_ra.go
@@ -0,0 +1,172 @@
+// 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"
+ "time"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/client"
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// WorkloadIdentityAWSRACommand implements `tbot start workload-identity-aws-ra`
+// and `tbot configure workload-identity-aws-ra`.
+type WorkloadIdentityAWSRACommand 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
+
+ // RoleARN is the ARN of the role to assume.
+ // Example: `arn:aws:iam::123456789012:role/example-role`
+ // Required.
+ RoleARN string
+ // ProfileARN is the ARN of the Roles Anywhere profile to use.
+ // Example: `arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000`
+ // Required.
+ ProfileARN string
+ // TrustAnchorARN is the ARN of the Roles Anywhere trust anchor to use.
+ // Example: `arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000`
+ // Required.
+ TrustAnchorARN string
+ // Region is the AWS region to use.
+ // Example: `us-east-1`
+ // Must be set here or in the environment or AWS config using the
+ // `AWS_REGION` environment variable. If set here, this will override the
+ // environment or AWS config.
+ Region string
+
+ // SessionDuration is the duration of the resulting AWS session and
+ // credentials. This may be up to 12 hours. When unset, this defaults to
+ // 6 hours.
+ SessionDuration time.Duration
+ // SessionRenewalInterval is the interval at which the session should be
+ // renewed. This should be less than the session duration. When unset, this
+ // defaults to 1 hour.
+ SessionRenewalInterval time.Duration
+}
+
+// NewWorkloadIdentityAWSRACommand initializes the command and flags for the
+// `workload-identity-aws-ra` output and returns a struct that will contain the parse
+// result.
+func NewWorkloadIdentityAWSRACommand(
+ parentCmd *kingpin.CmdClause, action MutatorAction, mode CommandMode,
+) *WorkloadIdentityAWSRACommand {
+ cmd := parentCmd.Command(
+ "workload-identity-aws-roles-anywhere",
+ fmt.Sprintf(
+ "%s tbot with an output containing AWS credentials generated via AWS Roles Anywhere.",
+ mode,
+ ),
+ )
+
+ c := &WorkloadIdentityAWSRACommand{}
+ 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(
+ "role-arn",
+ "The ARN of the role to assume.",
+ ).Required().StringVar(&c.RoleARN)
+ cmd.Flag(
+ "profile-arn",
+ "The ARN of the Roles Anywhere profile to use.",
+ ).Required().StringVar(&c.ProfileARN)
+ cmd.Flag(
+ "trust-anchor-arn",
+ "The ARN of the Roles Anywhere trust anchor to use.",
+ ).Required().StringVar(&c.TrustAnchorARN)
+ cmd.Flag(
+ "region",
+ "The AWS region to use. If unset, value will be used from the AWS config or the AWS_REGION environment variable.",
+ ).StringVar(&c.Region)
+
+ cmd.Flag(
+ "session-duration",
+ "The duration of the resulting AWS session and credentials. This may be up to 12 hours. When unset, this defaults to 6 hours.",
+ ).DurationVar(&c.SessionDuration)
+ cmd.Flag(
+ "session-renewal-interval",
+ "How often the session should be renewed. This should be less than the session duration. When unset, this defaults to 1 hour.",
+ ).DurationVar(&c.SessionRenewalInterval)
+
+ return c
+}
+
+// ApplyConfig applies the parsed flags to the bot configuration.
+func (c *WorkloadIdentityAWSRACommand) 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.WorkloadIdentityAWSRAService{
+ Destination: dest,
+ RoleARN: c.RoleARN,
+ ProfileARN: c.ProfileARN,
+ TrustAnchorARN: c.TrustAnchorARN,
+ Region: c.Region,
+ SessionDuration: c.SessionDuration,
+ SessionRenewalInterval: c.SessionRenewalInterval,
+ }
+
+ 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_aws_ra_test.go b/lib/tbot/cli/start_workload_identity_aws_ra_test.go
new file mode 100644
index 0000000000000..0b4e974b88091
--- /dev/null
+++ b/lib/tbot/cli/start_workload_identity_aws_ra_test.go
@@ -0,0 +1,121 @@
+// 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"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+func TestWorkloadIdentityAWSRACommand(t *testing.T) {
+ testStartConfigureCommand(t, NewWorkloadIdentityAWSRACommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "workload-identity-aws-roles-anywhere",
+ "--destination=/bar",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--label-selector=*=*,foo=bar",
+ "--role-arn=arn:aws:iam::123456789012:role/example-role",
+ "--trust-anchor-arn=arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ "--profile-arn=arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ "--region=us-east-1",
+ "--session-duration=2h",
+ "--session-renewal-interval=30m",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ svc := cfg.Services[0]
+ wis, ok := svc.(*config.WorkloadIdentityAWSRAService)
+ 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, "arn:aws:iam::123456789012:role/example-role", wis.RoleARN)
+ require.Equal(
+ t,
+ "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ wis.TrustAnchorARN,
+ )
+ require.Equal(
+ t,
+ "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ wis.ProfileARN,
+ )
+ require.Equal(t, "us-east-1", wis.Region)
+
+ require.Equal(t, 2*time.Hour, wis.SessionDuration)
+ require.Equal(t, 30*time.Minute, wis.SessionRenewalInterval)
+ },
+ },
+ {
+ name: "success name selector",
+ args: []string{
+ "start",
+ "workload-identity-aws-roles-anywhere",
+ "--destination=/bar",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--name-selector=jim",
+ "--role-arn=arn:aws:iam::123456789012:role/example-role",
+ "--trust-anchor-arn=arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ "--profile-arn=arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ "--region=us-east-1",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ svc := cfg.Services[0]
+ wis, ok := svc.(*config.WorkloadIdentityAWSRAService)
+ 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, "arn:aws:iam::123456789012:role/example-role", wis.RoleARN)
+ require.Equal(
+ t,
+ "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ wis.TrustAnchorARN,
+ )
+ require.Equal(
+ t,
+ "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ wis.ProfileARN,
+ )
+ require.Equal(t, "us-east-1", wis.Region)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go
index 7c56766ce7ccd..6df5330b75549 100644
--- a/lib/tbot/config/config.go
+++ b/lib/tbot/config/config.go
@@ -456,6 +456,12 @@ func (o *ServiceConfigs) UnmarshalYAML(node *yaml.Node) error {
return trace.Wrap(err)
}
out = append(out, v)
+ case WorkloadIdentityAWSRAType:
+ v := &WorkloadIdentityAWSRAService{}
+ 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/service_workload_identity_aws_ra.go b/lib/tbot/config/service_workload_identity_aws_ra.go
new file mode 100644
index 0000000000000..eb81a3c07beda
--- /dev/null
+++ b/lib/tbot/config/service_workload_identity_aws_ra.go
@@ -0,0 +1,181 @@
+// 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"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws/arn"
+ "github.com/gravitational/trace"
+ "gopkg.in/yaml.v3"
+
+ "github.com/gravitational/teleport/api/utils/aws"
+ "github.com/gravitational/teleport/lib/tbot/bot"
+)
+
+const (
+ WorkloadIdentityAWSRAType = "workload-identity-aws-roles-anywhere"
+ defaultAWSSessionDuration = 6 * time.Hour
+ maxAWSSessionDuration = 12 * time.Hour
+ defaultAWSSessionRenewalInterval = 1 * time.Hour
+)
+
+var (
+ _ ServiceConfig = &WorkloadIdentityAWSRAService{}
+ _ Initable = &WorkloadIdentityAWSRAService{}
+)
+
+// WorkloadIdentityAWSRAService is the configuration for the
+// WorkloadIdentityAWSRAService
+type WorkloadIdentityAWSRAService 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"`
+
+ // RoleARN is the ARN of the role to assume.
+ // Example: `arn:aws:iam::123456789012:role/example-role`
+ // Required.
+ RoleARN string `yaml:"role_arn"`
+ // ProfileARN is the ARN of the Roles Anywhere profile to use.
+ // Example: `arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000`
+ // Required.
+ ProfileARN string `yaml:"profile_arn"`
+ // TrustAnchorARN is the ARN of the Roles Anywhere trust anchor to use.
+ // Example: `arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000`
+ // Required.
+ TrustAnchorARN string `yaml:"trust_anchor_arn"`
+ // Region is the AWS region to use.
+ // Example: `us-east-1`
+ // Must be set here or in the environment or AWS config using the
+ // `AWS_REGION` environment variable. If set here, this will override the
+ // environment or AWS config.
+ Region string `yaml:"region"`
+
+ // SessionDuration is the duration of the resulting AWS session and
+ // credentials. This may be up to 12 hours. When unset, this defaults to
+ // 6 hours.
+ SessionDuration time.Duration `yaml:"session_duration"`
+ // SessionRenewalInterval is the interval at which the session should be
+ // renewed. This should be less than the session duration. When unset, this
+ // defaults to 1 hour.
+ SessionRenewalInterval time.Duration `yaml:"session_renewal_interval"`
+
+ // EndpointOverride is the endpoint to use for the AWS Roles Anywhere service.
+ // This is designed to be leveraged by tests and unset in production
+ // circumstances.
+ EndpointOverride string `yaml:"-"`
+}
+
+// Init initializes the destination.
+func (o *WorkloadIdentityAWSRAService) Init(ctx context.Context) error {
+ return trace.Wrap(o.Destination.Init(ctx, []string{}))
+}
+
+// CheckAndSetDefaults checks the WorkloadIdentityAWSRAService values and sets any defaults.
+func (o *WorkloadIdentityAWSRAService) 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")
+ }
+
+ switch {
+ case o.RoleARN == "":
+ return trace.BadParameter("role_arn: must be set")
+ case o.ProfileARN == "":
+ return trace.BadParameter("profile_arn: must be set")
+ case o.TrustAnchorARN == "":
+ return trace.BadParameter("trust_anchor_arn: must be set")
+ }
+ if _, err := arn.Parse(o.RoleARN); err != nil {
+ return trace.Wrap(err, "parsing role_arn")
+ }
+ if _, err := arn.Parse(o.ProfileARN); err != nil {
+ return trace.Wrap(err, "parsing profile_arn")
+ }
+ if _, err := arn.Parse(o.TrustAnchorARN); err != nil {
+ return trace.Wrap(err, "parsing trust_anchor_arn")
+ }
+ if o.Region != "" {
+ if err := aws.IsValidRegion(o.Region); err != nil {
+ return trace.Wrap(err, "validating region")
+ }
+ }
+
+ if o.SessionDuration == 0 {
+ o.SessionDuration = defaultAWSSessionDuration
+ }
+ if o.SessionDuration > maxAWSSessionDuration {
+ return trace.BadParameter("session_duration: must be less than or equal to 12 hours")
+ }
+ if o.SessionRenewalInterval == 0 {
+ o.SessionRenewalInterval = defaultAWSSessionRenewalInterval
+ }
+ if o.SessionRenewalInterval >= o.SessionDuration {
+ return trace.BadParameter("session_renewal_interval: must be less than session_duration")
+ }
+
+ return nil
+}
+
+// Describe returns the file descriptions for the WorkloadIdentityJWTService.
+func (o *WorkloadIdentityAWSRAService) Describe() []FileDescription {
+ fds := []FileDescription{
+ {
+ Name: JWTSVIDPath,
+ },
+ }
+ return fds
+}
+
+func (o *WorkloadIdentityAWSRAService) Type() string {
+ return WorkloadIdentityAWSRAType
+}
+
+// MarshalYAML marshals the WorkloadIdentityJWTService into YAML.
+func (o *WorkloadIdentityAWSRAService) MarshalYAML() (interface{}, error) {
+ type raw WorkloadIdentityAWSRAService
+ return withTypeHeader((*raw)(o), WorkloadIdentityAWSRAType)
+}
+
+// UnmarshalYAML unmarshals the WorkloadIdentityJWTService from YAML.
+func (o *WorkloadIdentityAWSRAService) 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 WorkloadIdentityAWSRAService
+ if err := node.Decode((*raw)(o)); err != nil {
+ return trace.Wrap(err)
+ }
+ o.Destination = dest
+ return nil
+}
+
+// GetDestination returns the destination.
+func (o *WorkloadIdentityAWSRAService) GetDestination() bot.Destination {
+ return o.Destination
+}
+
+func (o *WorkloadIdentityAWSRAService) GetCredentialLifetime() CredentialLifetime {
+ return CredentialLifetime{}
+}
diff --git a/lib/tbot/config/service_workload_identity_aws_ra_test.go b/lib/tbot/config/service_workload_identity_aws_ra_test.go
new file mode 100644
index 0000000000000..0ed20e1e6e87a
--- /dev/null
+++ b/lib/tbot/config/service_workload_identity_aws_ra_test.go
@@ -0,0 +1,308 @@
+// 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"
+ "time"
+
+ "github.com/gravitational/teleport/lib/tbot/botfs"
+)
+
+func TestWorkloadIdentityAWSRAService_YAML(t *testing.T) {
+ t.Parallel()
+
+ dest := &DestinationMemory{}
+ tests := []testYAMLCase[WorkloadIdentityAWSRAService]{
+ {
+ name: "full",
+ in: WorkloadIdentityAWSRAService{
+ Destination: dest,
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ SessionDuration: time.Minute * 59,
+ SessionRenewalInterval: time.Minute * 29,
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ },
+ },
+ }
+ testYAML(t, tests)
+}
+
+func TestWorkloadIdentityAWSRAService_CheckAndSetDefaults(t *testing.T) {
+ t.Parallel()
+
+ tests := []testCheckAndSetDefaultsCase[*WorkloadIdentityAWSRAService]{
+ {
+ name: "valid",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ want: &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ SessionDuration: defaultAWSSessionDuration,
+ SessionRenewalInterval: defaultAWSSessionRenewalInterval,
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ },
+ },
+ {
+ name: "valid with labels",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Labels: map[string][]string{
+ "key": {"value"},
+ },
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ SessionDuration: 1 * time.Hour,
+ SessionRenewalInterval: 30 * time.Minute,
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ },
+ {
+ name: "missing selectors",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{},
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "one of ['name', 'labels'] must be set",
+ },
+ {
+ name: "too many selectors",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ Labels: map[string][]string{
+ "key": {"value"},
+ },
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "at most one of ['name', 'labels'] can be set",
+ },
+ {
+ name: "missing destination",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Destination: nil,
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "no destination configured for output",
+ },
+ {
+ name: "missing role_arn",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "role_arn: must be set",
+ },
+ {
+ name: "missing trust_anchor_arn",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "trust_anchor_arn: must be set",
+ },
+ {
+ name: "missing profile_arn",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "profile_arn: must be set",
+ },
+ {
+ name: "invalid role arn",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "foo",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "arn: invalid prefix",
+ },
+ {
+ name: "invalid trust anchor arn",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "foo",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "arn: invalid prefix",
+ },
+ {
+ name: "invalid profile arn",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "foo",
+ Region: "us-east-1",
+ }
+ },
+ wantErr: "arn: invalid prefix",
+ },
+ {
+ name: "invalid region",
+ in: func() *WorkloadIdentityAWSRAService {
+ return &WorkloadIdentityAWSRAService{
+ Selector: WorkloadIdentitySelector{
+ Name: "my-workload-identity",
+ },
+ Destination: &DestinationDirectory{
+ Path: "/opt/machine-id",
+ ACLs: botfs.ACLOff,
+ Symlinks: botfs.SymlinksInsecure,
+ },
+ RoleARN: "arn:aws:iam::123456789012:role/example-role",
+ TrustAnchorARN: "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000",
+ ProfileARN: "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000",
+ Region: "us-east-1!!!!",
+ }
+ },
+ wantErr: "validating region",
+ },
+ }
+ testCheckAndSetDefaults(t, tests)
+}
diff --git a/lib/tbot/config/testdata/TestWorkloadIdentityAWSRAService_YAML/full.golden b/lib/tbot/config/testdata/TestWorkloadIdentityAWSRAService_YAML/full.golden
new file mode 100644
index 0000000000000..4f3a9812064b9
--- /dev/null
+++ b/lib/tbot/config/testdata/TestWorkloadIdentityAWSRAService_YAML/full.golden
@@ -0,0 +1,11 @@
+type: workload-identity-aws-roles-anywhere
+selector:
+ name: my-workload-identity
+destination:
+ type: memory
+role_arn: arn:aws:iam::123456789012:role/example-role
+profile_arn: arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000
+trust_anchor_arn: arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000
+region: us-east-1
+session_duration: 59m0s
+session_renewal_interval: 29m0s
diff --git a/lib/tbot/service_workload_identity_aws_ra.go b/lib/tbot/service_workload_identity_aws_ra.go
new file mode 100644
index 0000000000000..1f3167fc04b30
--- /dev/null
+++ b/lib/tbot/service_workload_identity_aws_ra.go
@@ -0,0 +1,260 @@
+// 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 (
+ "bytes"
+ "context"
+ "crypto"
+ "crypto/x509"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/gravitational/trace"
+ awsspiffe "github.com/spiffe/aws-spiffe-workload-helper"
+ "github.com/spiffe/aws-spiffe-workload-helper/vendoredaws"
+ "github.com/spiffe/go-spiffe/v2/svid/x509svid"
+ "gopkg.in/ini.v1"
+
+ workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
+ "github.com/gravitational/teleport/lib/auth/authclient"
+ "github.com/gravitational/teleport/lib/reversetunnelclient"
+ "github.com/gravitational/teleport/lib/tbot/bot"
+ "github.com/gravitational/teleport/lib/tbot/config"
+ "github.com/gravitational/teleport/lib/tbot/identity"
+ "github.com/gravitational/teleport/lib/tbot/workloadidentity"
+)
+
+// WorkloadIdentityAWSRAService is a service that retrieves X.509 certificates
+// and exchanges them for AWS credentials using the AWS Roles Anywhere service.
+type WorkloadIdentityAWSRAService struct {
+ botAuthClient *authclient.Client
+ botCfg *config.BotConfig
+ cfg *config.WorkloadIdentityAWSRAService
+ getBotIdentity getBotIdentityFn
+ log *slog.Logger
+ resolver reversetunnelclient.Resolver
+ reloadBroadcaster *channelBroadcaster
+}
+
+// String returns a human-readable description of the service.
+func (s *WorkloadIdentityAWSRAService) String() string {
+ return fmt.Sprintf("workload-identity-aws-roles-anywhere (%s)", s.cfg.Destination.String())
+}
+
+// OneShot runs the service once, generating the output and writing it to the
+// destination, before exiting.
+func (s *WorkloadIdentityAWSRAService) OneShot(ctx context.Context) error {
+ return s.generate(ctx)
+}
+
+// Run runs the service in a loop, generating the output and writing it to the
+// destination at regular intervals.
+func (s *WorkloadIdentityAWSRAService) Run(ctx context.Context) error {
+ reloadCh, unsubscribe := s.reloadBroadcaster.subscribe()
+ defer unsubscribe()
+
+ err := runOnInterval(ctx, runOnIntervalConfig{
+ service: s.String(),
+ name: "output-renewal",
+ f: s.generate,
+ interval: s.cfg.SessionRenewalInterval,
+ retryLimit: renewalRetryLimit,
+ log: s.log,
+ reloadCh: reloadCh,
+ })
+ return trace.Wrap(err)
+}
+
+func (s *WorkloadIdentityAWSRAService) generate(ctx context.Context) error {
+ res, privateKey, err := s.requestSVID(ctx)
+ if err != nil {
+ return trace.Wrap(err, "requesting SVID")
+ }
+ pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
+ if err != nil {
+ return trace.Wrap(err, "marshaling private key")
+ }
+ svid, err := x509svid.ParseRaw(res.GetX509Svid().Cert, pkcs8)
+ if err != nil {
+ return trace.Wrap(err, "parsing x509 svid")
+ }
+
+ s.log.InfoContext(
+ ctx,
+ "Exchanging SVID for AWS credentials",
+ "spiffe_id", svid.ID.String(),
+ "role_arn", s.cfg.RoleARN,
+ "profile_arn", s.cfg.ProfileARN,
+ "trust_anchor_arn", s.cfg.TrustAnchorARN,
+ )
+ creds, err := s.exchangeSVID(svid)
+ if err != nil {
+ return trace.Wrap(err, "exchanging SVID via Roles Anywhere")
+ }
+ s.log.InfoContext(
+ ctx,
+ "Exchanged SVID for AWS credentials",
+ "aws_credentials_expiry", creds.Expiration,
+ )
+
+ return renderAWSCreds(ctx, creds, s.cfg.Destination)
+}
+
+// exchangeSVID will exchange the X.509 SVID for AWS credentials using the
+// AWS Roles Anywhere service.
+func (s *WorkloadIdentityAWSRAService) exchangeSVID(
+ svid *x509svid.SVID,
+) (*vendoredaws.CredentialProcessOutput, error) {
+ signer := &awsspiffe.X509SVIDSigner{
+ SVID: svid,
+ }
+ algo, err := signer.SignatureAlgorithm()
+ if err != nil {
+ return nil, trace.Wrap(err, "getting signature algorithm")
+ }
+
+ credentials, err := vendoredaws.GenerateCredentials(&vendoredaws.CredentialsOpts{
+ RoleArn: s.cfg.RoleARN,
+ ProfileArnStr: s.cfg.ProfileARN,
+ Region: s.cfg.Region,
+ TrustAnchorArnStr: s.cfg.TrustAnchorARN,
+ SessionDuration: int(s.cfg.SessionDuration.Seconds()),
+ Endpoint: s.cfg.EndpointOverride,
+ }, signer, algo)
+ if err != nil {
+ return nil, trace.Wrap(err, "exchanging credentials")
+ }
+
+ return &credentials, nil
+}
+
+func (s *WorkloadIdentityAWSRAService) requestSVID(
+ ctx context.Context,
+) (
+ *workloadidentityv1pb.Credential,
+ crypto.Signer,
+ error,
+) {
+ ctx, span := tracer.Start(
+ ctx,
+ "WorkloadIdentityAWSRAService/requestSVID",
+ )
+ defer span.End()
+
+ roles, err := fetchDefaultRoles(ctx, s.botAuthClient, s.getBotIdentity())
+ if err != nil {
+ return nil, nil, trace.Wrap(err, "fetching roles")
+ }
+
+ id, err := generateIdentity(
+ ctx,
+ s.botAuthClient,
+ s.getBotIdentity(),
+ roles,
+ // We only need this to issue the X509 SVID, so we don't need the full
+ // lifetime.
+ time.Minute*10,
+ nil,
+ )
+ if err != nil {
+ return nil, 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, nil, trace.Wrap(err)
+ }
+ defer impersonatedClient.Close()
+
+ x509Credentials, privateKey, err := workloadidentity.IssueX509WorkloadIdentity(
+ ctx,
+ s.log,
+ impersonatedClient,
+ s.cfg.Selector,
+ // We only use this SVID to exchange for AWS credentials, so we don't
+ // need the full lifetime.
+ time.Minute*10,
+ nil,
+ )
+ if err != nil {
+ return nil, nil, trace.Wrap(err, "generating X509 SVID")
+ }
+ var x509Credential *workloadidentityv1pb.Credential
+ switch len(x509Credentials) {
+ case 0:
+ return nil, nil, trace.BadParameter("no X509 SVIDs returned")
+ case 1:
+ x509Credential = x509Credentials[0]
+ default:
+ // We could eventually implement some kind of hint selection mechanism
+ // to pick the "right" one.
+ received := make([]string, 0, len(x509Credentials))
+ for _, cred := range x509Credentials {
+ received = append(received,
+ fmt.Sprintf(
+ "%s:%s",
+ cred.WorkloadIdentityName,
+ cred.SpiffeId,
+ ),
+ )
+ }
+ return nil, nil, trace.BadParameter(
+ "multiple X509 SVIDs received: %v", received,
+ )
+ }
+
+ return x509Credential, privateKey, nil
+}
+
+// render will write the AWS credentials to the AWS CLI configuration file.
+// See https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-files.html
+func renderAWSCreds(
+ ctx context.Context, creds *vendoredaws.CredentialProcessOutput, dest bot.Destination,
+) error {
+ ctx, span := tracer.Start(
+ ctx,
+ "renderAWSCreds",
+ )
+ defer span.End()
+
+ // TODO(noah): At a later date, we can add a mode where we read in and
+ // modify an existing profile within the credentials file - without
+ // overwriting other profiles.
+ f := ini.Empty()
+ // "default" is the name of the section that the AWS CLI will use by
+ // default.
+ sec := f.Section("default")
+ sec.Key("aws_secret_access_key").SetValue(creds.SecretAccessKey)
+ sec.Key("aws_access_key_id").SetValue(creds.AccessKeyId)
+ sec.Key("aws_session_token").SetValue(creds.SessionToken)
+
+ b := &bytes.Buffer{}
+ _, err := f.WriteTo(b)
+ if err != nil {
+ return trace.Wrap(err, "writing credentials to buffer")
+ }
+
+ err = dest.Write(ctx, "aws_credentials", b.Bytes())
+ if err != nil {
+ return trace.Wrap(err, "writing credentials to destination")
+ }
+ return nil
+}
diff --git a/lib/tbot/service_workload_identity_aws_ra_test.go b/lib/tbot/service_workload_identity_aws_ra_test.go
new file mode 100644
index 0000000000000..41c509d0b6121
--- /dev/null
+++ b/lib/tbot/service_workload_identity_aws_ra_test.go
@@ -0,0 +1,215 @@
+// 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"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/spiffe/aws-spiffe-workload-helper/vendoredaws"
+ "github.com/stretchr/testify/assert"
+ "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/tbot/config"
+ "github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/teleport/lib/utils/testutils/golden"
+ "github.com/gravitational/teleport/tool/teleport/testenv"
+)
+
+func Test_renderAWSCreds(t *testing.T) {
+ creds := &vendoredaws.CredentialProcessOutput{
+ AccessKeyId: "AKIAIOSFODNN7EXAMPLEAKID",
+ SessionToken: "AQoDYXdzEJrtyWJ4NjK7PiEXAMPLEST",
+ SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLESAK",
+ }
+ ctx := context.Background()
+
+ dest := &config.DestinationMemory{}
+ require.NoError(t, dest.CheckAndSetDefaults())
+ require.NoError(t, dest.Init(ctx, []string{}))
+
+ err := renderAWSCreds(ctx, creds, dest)
+ require.NoError(t, err)
+
+ got, err := dest.Read(ctx, "aws_credentials")
+ require.NoError(t, err)
+
+ if golden.ShouldSet() {
+ golden.Set(t, got)
+ }
+ require.Equal(t, golden.Get(t), got)
+}
+
+type mockCreateSessionInputBody struct {
+ DurationSeconds int `json:"durationSeconds"`
+}
+
+func TestBotWorkloadIdentityAWSRA(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ log := utils.NewSlogLoggerForTests()
+
+ process := testenv.MakeTestServer(t, defaultTestServerOpts(t, log))
+ rootClient := testenv.MakeDefaultAuthClient(t, process)
+
+ roleArn := "arn:aws:iam::123456789012:role/example-role"
+ trustAnchorArn := "arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000"
+ profileArn := "arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000"
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/sessions", r.URL.Path)
+ assert.Equal(t, http.MethodPost, r.Method)
+
+ // Check query parameter inputs
+ // The AWS documentation "lies" about these inputs using the JSON body
+ // - the rolesanywhere API client in
+ // `aws/rolesanywhere-credential-helper` uses query parameters for
+ // these.
+ assert.Equal(t, roleArn, r.URL.Query().Get("roleArn"))
+ assert.Equal(t, trustAnchorArn, r.URL.Query().Get("trustAnchorArn"))
+ assert.Equal(t, profileArn, r.URL.Query().Get("profileArn"))
+
+ // Check JSON body inputs
+ body := &mockCreateSessionInputBody{}
+ assert.NoError(t, json.NewDecoder(r.Body).Decode(body))
+ assert.Equal(t, int((2 * time.Hour).Seconds()), body.DurationSeconds)
+
+ // Validate the X-Amz-X509 header contains the valid (and correct) SVID
+ derString := r.Header.Get("X-Amz-X509")
+ assert.NotEmpty(t, derString)
+ derBytes, err := base64.StdEncoding.DecodeString(derString)
+ assert.NoError(t, err)
+ cert, err := x509.ParseCertificate(derBytes)
+ assert.NoError(t, err)
+ assert.Len(t, cert.URIs, 1)
+ assert.Equal(t, "spiffe://root/ra-test", cert.URIs[0].String())
+
+ // Validate the authorization header exists. We rely on the AWS SDK to
+ // actually produce the signature, and, validating this signature would
+ // introduce significant complexity to this test - so this is omitted.
+ authz := r.Header.Get("Authorization")
+ assert.NotEmpty(t, authz)
+
+ // Send mocked response
+ _, _ = w.Write([]byte(`{
+ "credentialSet":[
+ {
+ "assumedRoleUser": {
+ "arn": "arn:aws:iam::123456789012:role/example-role",
+ "assumedRoleId": "assumedRoleId"
+ },
+ "credentials":{
+ "accessKeyId": "accessKeyId",
+ "expiration": "2028-07-27T04:36:55Z",
+ "secretAccessKey": "secretAccessKey",
+ "sessionToken": "sessionToken"
+ },
+ "packedPolicySize": 10,
+ "roleArn": "arn:aws:iam::123456789012:role/example-role",
+ "sourceIdentity": "sourceIdentity"
+ }
+ ],
+ "subjectArn": "arn:aws:rolesanywhere:us-east-1:000000000000:subject/41cl0bae-6783-40d4-ab20-65dc5d922e45"
+ }`))
+ }))
+ t.Cleanup(srv.Close)
+
+ 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: "/ra-test",
+ },
+ },
+ }
+ workloadIdentity, err = rootClient.WorkloadIdentityResourceServiceClient().
+ CreateWorkloadIdentity(ctx, &workloadidentityv1pb.CreateWorkloadIdentityRequest{
+ WorkloadIdentity: workloadIdentity,
+ })
+ require.NoError(t, err)
+
+ tmpDir := t.TempDir()
+ onboarding, _ := makeBot(t, rootClient, "ra-test", role.GetName())
+ botConfig := defaultBotConfig(t, process, onboarding, config.ServiceConfigs{
+ &config.WorkloadIdentityAWSRAService{
+ Selector: config.WorkloadIdentitySelector{
+ Name: workloadIdentity.GetMetadata().GetName(),
+ },
+ Destination: &config.DestinationDirectory{
+ Path: tmpDir,
+ },
+ RoleARN: roleArn,
+ ProfileARN: profileArn,
+ TrustAnchorARN: trustAnchorArn,
+ Region: "us-east-1",
+ SessionDuration: 2 * time.Hour,
+ SessionRenewalInterval: 30 * time.Minute,
+ EndpointOverride: srv.URL,
+ },
+ }, 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)
+ t.Cleanup(cancel)
+ require.NoError(t, b.Run(ctx))
+
+ got, err := os.ReadFile(filepath.Join(tmpDir, "aws_credentials"))
+ require.NoError(t, err)
+ if golden.ShouldSet() {
+ golden.Set(t, got)
+ }
+ require.Equal(t, string(golden.Get(t)), string(got))
+}
diff --git a/lib/tbot/tbot.go b/lib/tbot/tbot.go
index 4964310fb30f8..a863a076cf8c0 100644
--- a/lib/tbot/tbot.go
+++ b/lib/tbot/tbot.go
@@ -616,6 +616,19 @@ func (b *Bot) Run(ctx context.Context) (err error) {
teleport.ComponentKey, teleport.Component(componentTBot, "svc", svc.String()),
)
services = append(services, svc)
+ case *config.WorkloadIdentityAWSRAService:
+ svc := &WorkloadIdentityAWSRAService{
+ botCfg: b.cfg,
+ cfg: svcCfg,
+ resolver: resolver,
+ botAuthClient: b.botIdentitySvc.GetClient(),
+ getBotIdentity: b.botIdentitySvc.GetIdentity,
+ reloadBroadcaster: reloadBroadcaster,
+ }
+ svc.log = b.log.With(
+ teleport.ComponentKey, teleport.Component(componentTBot, "svc", svc.String()),
+ )
+ services = append(services, svc)
default:
return trace.BadParameter("unknown service type: %T", svcCfg)
}
diff --git a/lib/tbot/testdata/TestBotWorkloadIdentityAWSRA.golden b/lib/tbot/testdata/TestBotWorkloadIdentityAWSRA.golden
new file mode 100644
index 0000000000000..ae1150fb2ee6d
--- /dev/null
+++ b/lib/tbot/testdata/TestBotWorkloadIdentityAWSRA.golden
@@ -0,0 +1,4 @@
+[default]
+aws_secret_access_key=secretAccessKey
+aws_access_key_id=accessKeyId
+aws_session_token=sessionToken
diff --git a/lib/tbot/testdata/Test_renderAWSCreds.golden b/lib/tbot/testdata/Test_renderAWSCreds.golden
new file mode 100644
index 0000000000000..0a55d7dad24b5
--- /dev/null
+++ b/lib/tbot/testdata/Test_renderAWSCreds.golden
@@ -0,0 +1,4 @@
+[default]
+aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLESAK
+aws_access_key_id=AKIAIOSFODNN7EXAMPLEAKID
+aws_session_token=AQoDYXdzEJrtyWJ4NjK7PiEXAMPLEST
diff --git a/tool/tbot/main.go b/tool/tbot/main.go
index e9e22d506934e..293d7c0a5d45e 100644
--- a/tool/tbot/main.go
+++ b/tool/tbot/main.go
@@ -152,6 +152,9 @@ func Run(args []string, stdout io.Writer) error {
cli.NewWorkloadIdentityJWTCommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart),
cli.NewWorkloadIdentityJWTCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure),
+
+ cli.NewWorkloadIdentityAWSRACommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart),
+ cli.NewWorkloadIdentityAWSRACommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure),
)
// Initialize legacy-style commands. These are simple enough to not really