Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,980 changes: 994 additions & 986 deletions api/client/proto/authservice.pb.go

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions api/proto/teleport/legacy/client/proto/authservice.proto
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ message UserCertsRequest {
// tunnel. Requests from this requester allows reuse of the MFA session
// response but TTL is limited to single use TTL.
TSH_DB_EXEC = 5;
// TSH_APP_AWS_CREDENTIALPROCESS is set when tsh provides access to an AWS App which uses client side credentials.
// When using per-session MFA, this ensures the TTL of the certificate (and thus the AWS session) is the same as the Teleport identity session.
// AWS credentials should not be written to disk when this requester is used, but may be exported as env variables through stdout.
TSH_APP_AWS_CREDENTIALPROCESS = 6;
}
// RequesterName identifies who sent the request.
Requester RequesterName = 17 [(gogoproto.jsontag) = "requester_name"];
Expand Down
7 changes: 7 additions & 0 deletions lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2647,6 +2647,8 @@ type certRequest struct {
// joinAttributes holds attributes derived from attested metadata from the
// join process, should any exist.
joinAttributes *workloadidentityv1pb.JoinAttrs
// requesterName is the name of the service that sent the request.
requesterName proto.UserCertsRequest_Requester
}

// check verifies the cert request is valid.
Expand Down Expand Up @@ -3783,6 +3785,11 @@ func generateCert(ctx context.Context, a *Server, req certRequest, caType types.
awsCredentialProcessCredentials, err := generateAWSClientSideCredentials(ctx, a, req, notAfter)
switch {
case errors.Is(err, errAppWithoutAWSClientSideCredentials):
// Requesting AWS credential_process credentials for Apps without AWS client side credentials is a client error.
if req.requesterName == proto.UserCertsRequest_TSH_APP_AWS_CREDENTIALPROCESS {
return nil, trace.BadParameter("client requested aws credentials for an invalid resource")
}

case err != nil:
return nil, trace.Wrap(err)
}
Expand Down
8 changes: 5 additions & 3 deletions lib/auth/auth_with_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -3430,9 +3430,10 @@ func (a *ServerWithRoles) generateUserCerts(ctx context.Context, req proto.UserC
if maxTime := a.authServer.GetClock().Now().Add(defaults.MaxRenewableCertTTL); req.Expires.After(maxTime) {
req.Expires = maxTime
}
} else if isLocalProxyCertReq(&req) {
// If requested certificate is for headless Kubernetes access of local proxy it is limited by max session ttl
// or mfa_verification_interval or req.Expires.
} else if !isCertWrittenToDiskFlow(&req) {
// If requested certificate is for a flow that does not involve writing the certificate to disk
// (e.g. tsh proxy of DB, Kube, App, and AWS App Access using credential process)
// it is limited by max session ttl or mfa_verification_interval or req.Expires.

// Calculate the expiration time.
roleSet, err := services.FetchRoles(user.GetRoles(), a, user.GetTraits())
Expand Down Expand Up @@ -3613,6 +3614,7 @@ func (a *ServerWithRoles) generateUserCerts(ctx context.Context, req proto.UserC
tlsPublicKeyAttestationStatement: hardwarekey.AttestationStatementFromProto(req.TLSPublicKeyAttestationStatement),
overrideRoleTTL: a.hasBuiltinRole(types.RoleAdmin),
routeToCluster: req.RouteToCluster,
requesterName: req.RequesterName,
kubernetesCluster: req.KubernetesCluster,
dbService: req.RouteToDatabase.ServiceName,
dbProtocol: req.RouteToDatabase.Protocol,
Expand Down
13 changes: 13 additions & 0 deletions lib/auth/auth_with_roles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,19 @@ func TestAppAccessUsingAWSOIDC_doesntGenerateClientCredentials(t *testing.T) {
pub, err := keys.MarshalPublicKey(priv.Public())
require.NoError(t, err)

// Impersonating the requester name fails.
_, err = client.GenerateUserCerts(ctx, proto.UserCertsRequest{
TLSPublicKey: pub,
Username: user.GetName(),
Expires: time.Now().Add(time.Hour),
RouteToApp: proto.RouteToApp{
Name: appName,
AWSRoleARN: roleARN,
},
RequesterName: proto.UserCertsRequest_TSH_APP_AWS_CREDENTIALPROCESS,
})
require.Error(t, err)

certs, err := client.GenerateUserCerts(ctx, proto.UserCertsRequest{
TLSPublicKey: pub,
Username: user.GetName(),
Expand Down
30 changes: 23 additions & 7 deletions lib/auth/grpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -2704,11 +2704,10 @@ func (g *GRPCServer) GenerateUserSingleUseCerts(stream authpb.AuthService_Genera
}

func setUserSingleUseCertsTTL(actx *grpcContext, req *authpb.UserCertsRequest) {
if isLocalProxyCertReq(req) {
// don't limit the cert expiry to 1 minute for db local proxy tunnel or kube local proxy,
// because the certs will be kept in-memory by the client to protect
// against cert/key exfiltration. When MFA is required, cert expiration
// time is bounded by the lifetime of the local proxy process or the mfa verification interval.
if !isCertWrittenToDiskFlow(req) {
// Don't limit the cert expiry to 1 minute for certs that are not written to disk.
// When MFA is required, cert expiration time is bounded by the lifetime of the local proxy process
// or the mfa verification interval.
return
}

Expand All @@ -2718,8 +2717,11 @@ func setUserSingleUseCertsTTL(actx *grpcContext, req *authpb.UserCertsRequest) {
}
}

// isLocalProxyCertReq returns whether a cert request is for a local proxy cert.
func isLocalProxyCertReq(req *authpb.UserCertsRequest) bool {
// isInMemoryCertRequest returns whether a cert request is for a flow where the certificate is kept in-memory by the client.
// The certificate should not be exposed through disk, stdout, a socket, etc.
// For those scenarios, we can issue certs with longer TTLs even when they are single-use certs.
// This is the case for cert requests made by tsh db/kube/app local proxy.
func isInMemoryCertRequest(req *authpb.UserCertsRequest) bool {
return (req.Usage == authpb.UserCertsRequest_Database &&
req.RequesterName == authpb.UserCertsRequest_TSH_DB_LOCAL_PROXY_TUNNEL) ||
(req.Usage == authpb.UserCertsRequest_Kubernetes &&
Expand All @@ -2728,6 +2730,20 @@ func isLocalProxyCertReq(req *authpb.UserCertsRequest) bool {
req.RequesterName == authpb.UserCertsRequest_TSH_APP_LOCAL_PROXY)
}

// isCredentialsStdoutCertRequest returns whether a cert request is for a flow where the credentials are not written to disk, but are sent to stdout.
// For those scenarios, we can issue certs with longer TTLs even when they are single-use certs.
// This is the case for cert requests made by tsh for an AWS App access which writes the credentials to stdout.
// Note: this is different from isInMemoryCertRequest because the credentials are written to stdout instead of being kept in-memory.
func isCredentialsStdoutCertRequest(req *authpb.UserCertsRequest) bool {
return req.Usage == authpb.UserCertsRequest_App && req.RequesterName == authpb.UserCertsRequest_TSH_APP_AWS_CREDENTIALPROCESS
}

// isCertWrittenToDiskFlow returns whether a cert request is for a flow where the certificate is written to disk.
// For those scenarios, we need to limit the cert TTL to avoid long-lived single-use certs.
func isCertWrittenToDiskFlow(req *authpb.UserCertsRequest) bool {
return !isInMemoryCertRequest(req) && !isCredentialsStdoutCertRequest(req)
}

func userSingleUseCertsGenerate(ctx context.Context, actx *grpcContext, req authpb.UserCertsRequest) (*authpb.Certs, error) {
// Get the client IP.
clientPeer, ok := peer.FromContext(ctx)
Expand Down
74 changes: 74 additions & 0 deletions lib/auth/grpcserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import (
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/defaults"
dtauthz "github.com/gravitational/teleport/lib/devicetrust/authz"
"github.com/gravitational/teleport/lib/integrations/awsra/createsession"
iterstream "github.com/gravitational/teleport/lib/itertools/stream"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/modules/modulestest"
Expand Down Expand Up @@ -1536,6 +1537,49 @@ func TestGenerateUserCerts_singleUseCerts(t *testing.T) {
_, err = srv.Auth().UpsertApplicationServer(ctx, appServer)
require.NoError(t, err)

// Roles Anywhere set up.
srv.Auth().AWSRolesAnywhereCreateSessionOverride = func(ctx context.Context, req createsession.CreateSessionRequest) (*createsession.CreateSessionResponse, error) {
return &createsession.CreateSessionResponse{
Version: 1,
AccessKeyID: "aki",
SecretAccessKey: "sak",
SessionToken: "st",
Expiration: "2025-06-25T12:07:02.474135Z",
}, nil
}
awsRAIntegration := "ra-integration"
ig, err := types.NewIntegrationAWSRA(types.Metadata{Name: awsRAIntegration}, &types.AWSRAIntegrationSpecV1{
TrustAnchorARN: "arn:aws:rolesanywhere:eu-west-2:123456789012:trust-anchor/ExampleTrustAnchor",
ProfileSyncConfig: &types.AWSRolesAnywhereProfileSyncConfig{
ProfileARN: "arn:aws:rolesanywhere:eu-west-2:123456789012:profile/uuid2",
RoleARN: "arn:aws:iam::123456789012:role/SyncRole",
},
})
require.NoError(t, err)
_, err = srv.Auth().Integrations.CreateIntegration(ctx, ig)
require.NoError(t, err)

awsAppUsingRolesAnywhere, err := types.NewAppServerV3(types.Metadata{
Name: "app-roles-anywhere",
}, types.AppServerSpecV3{
HostID: srv.Auth().ServerID,
App: &types.AppV3{Metadata: types.Metadata{
Name: "app-roles-anywhere",
}, Spec: types.AppSpecV3{
URI: constants.AWSConsoleURL,
Integration: awsRAIntegration,
AWS: &types.AppAWS{
RolesAnywhereProfile: &types.AppAWSRolesAnywhereProfile{
ProfileARN: "arn:aws:rolesanywhere:eu-west-2:123456789012:profile/12345678-1234-1234-1234-123456789012",
},
},
PublicAddr: "example.com",
}},
})
require.NoError(t, err)
_, err = srv.Auth().UpsertApplicationServer(ctx, awsAppUsingRolesAnywhere)
require.NoError(t, err)

leaf, err := types.NewRemoteCluster("leaf")
require.NoError(t, err)

Expand Down Expand Up @@ -2005,6 +2049,36 @@ func TestGenerateUserCerts_singleUseCerts(t *testing.T) {
},
},
},
{
desc: "aws app using roles anywhere should not limit ttl to 1m",
opts: generateUserSingleUseCertsTestOpts{
initReq: &proto.UserCertsRequest{
TLSPublicKey: tlsPub,
Username: user.GetName(),
// Expiration should be adjusted to user cert ttl, but not to single user cert TTL (1min).
Expires: clock.Now().Add(1000 * time.Hour),
Usage: proto.UserCertsRequest_App,
RouteToApp: proto.RouteToApp{
Name: "app-roles-anywhere",
AWSRoleARN: "arn:aws:iam::123456789012:role/MyRole",
},
RequesterName: proto.UserCertsRequest_TSH_APP_AWS_CREDENTIALPROCESS,
},
authnHandler: registered.webAuthHandler,
verifyErr: require.NoError,
verifyCert: func(t *testing.T, c *proto.Certs) {
cert, err := tlsca.ParseCertificatePEM(c.TLS)
require.NoError(t, err)
require.Equal(t, userCertExpires, cert.NotAfter)
identity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
require.NoError(t, err)
require.Equal(t, webDevID, identity.MFAVerified)
require.Equal(t, userCertExpires, identity.PreviousIdentityExpires)
require.Equal(t, []string{teleport.UsageAppsOnly}, identity.Usage)
require.NotEmpty(t, identity.RouteToApp.AWSCredentialProcessCredentials)
},
},
},
{
desc: "desktops",
opts: generateUserSingleUseCertsTestOpts{
Expand Down
24 changes: 24 additions & 0 deletions rfd/0169-app-mfa-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,30 @@ curl \
<a href="/login">Found</a>.
```

### `tsh app login` for AWS Access using AWS IAM Roles Anywhere integration credentials.

In this case, the AWS credentials must be provided to the AWS client through conventional means (e.g. `AWS_*` env vars or ~/.aws/config with credentials).
In order to keep these credentials single-use with per-session MFA, the user is required to immediately export the credentials as `AWS_*` env vars in their current shell session.

Note: unlike the local proxy flows where the cert is kept in-memory, we've determined that sharing the single-use AWS credentials through stdout / env vars cannot be avoided.
However, since the credentials are still kept off disk where they could be more easily exfiltrated, these AWS creds will be still be permitted to exceed the common 1 minute per-session MFA TTL.
The absolute minimum TTL permitted by AWS is 15 minutes, but these creds will be allowed to reach the user's max session TTL like the in-memory local proxy certs.

In this case, users must export the AWS credentials into the current shell session:
```console
> eval "$(tsh apps login myaws --aws-role arn:aws:iam::123456789012:role/MyRole --env)"
MFA is required to access Application "myaws"
Tap any security key
Detected security key tap

> aws sts get-caller-identity
{
"UserId": "USERID:00id123",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/MyRole/00id123"
}
```

#### `tsh proxy app`

Users can use `tsh proxy app` to create a local proxy for the app. This command
Expand Down
66 changes: 63 additions & 3 deletions tool/tsh/common/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,56 @@ func onAppLogin(cf *CLIConf) error {
AccessRequests: appInfo.profile.ActiveRequests,
}

// When using `tsh app login`, certs should generally be saved to disk, whether standard certs or
// single-use MFA-verified 1m TTL certs. However, in cases where we are exceeding the standard
// 1m TTL for single-use certs, we must ensure the certs are not saved to disk.
saveCertificateToDisk := true
if app.GetAWSRolesAnywhereProfileARN() != "" {
singleUseCerts, err := isMFARequiredForAppAccess(cf.Context, tc, appInfo.RouteToApp)
if err != nil {
return trace.Wrap(err)
}

if singleUseCerts {
// Prevent single use certificates from being written to disk.
saveCertificateToDisk = false

appCertParams.RequesterName = proto.UserCertsRequest_TSH_APP_AWS_CREDENTIALPROCESS

// When using single use certs (aka per-session MFA), tsh cannot write the certificate or AWS credentials to disk.
// Instead, ask user to use the `--env` flag which only outputs the credentials, in an eval friendly format.
if !cf.AppLoginAWSEnvOutput {
return trace.BadParameter(`AWS access is configured to use per-session MFA and credentials are only available to a single session. Pass the --env flag to the previous command and export the credentials using eval.
Example:
eval "$(tsh apps login %s --aws-role %s --env)"

You can now run the AWS CLI or other AWS SDK based tools as usual.
Example:
aws sts get-caller-identity`,
shsprintf.EscapeDefaultContext(app.GetName()),
shsprintf.EscapeDefaultContext(appInfo.RouteToApp.AWSRoleARN),
)
}
}
}

key, err := appLogin(cf.Context, clusterClient, rootClient, appCertParams)
if err != nil {
return trace.Wrap(err)
}

if err := tc.LocalAgent().AddAppKeyRing(key); err != nil {
return trace.Wrap(err)
if saveCertificateToDisk {
if err := tc.LocalAgent().AddAppKeyRing(key); err != nil {
return trace.Wrap(err)
}
}

appInfo, err = reloadAppInfoFromKeyring(app, appInfo, key)
if err != nil {
return trace.Wrap(err)
}

if err := writeFilesForExternalApps(appInfo); err != nil {
if err := writeFilesForExternalApps(cf.Stdout(), appInfo, cf.AppLoginAWSEnvOutput); err != nil {
return trace.Wrap(err)
}

Expand All @@ -131,6 +166,26 @@ func onAppLogin(cf *CLIConf) error {
return nil
}

// isMFARequiredForAppAccess calls the IsMFARequired endpoint in order to get from user roles if access to the application
// requires MFA.
func isMFARequiredForAppAccess(ctx context.Context, tc *client.TeleportClient, routeToApp proto.RouteToApp) (bool, error) {
clusterClient, err := tc.ConnectToCluster(ctx)
if err != nil {
return false, trace.Wrap(err)
}
defer clusterClient.Close()

mfaResp, err := clusterClient.AuthClient.IsMFARequired(ctx, &proto.IsMFARequiredRequest{
Target: &proto.IsMFARequiredRequest_App{
App: &routeToApp,
},
})
if err != nil {
return false, trace.Wrap(err)
}
return mfaResp.GetRequired(), nil
}

func reloadAppInfoFromKeyring(app types.Application, appInfo *appInfo, key *client.KeyRing) (*appInfo, error) {
// AWS Access ugin Roles Anywhere integration receive the credentials in the certificate.
// For all other apps, the routeToApp is already correct.
Expand Down Expand Up @@ -185,6 +240,11 @@ func printAppCommand(cf *CLIConf, tc *client.TeleportClient, app types.Applicati

switch {
case app.IsAWSConsole():
// When using env output, skip printing the login instructions because they were already emitted alongside the env var exports.
if cf.AppLoginAWSEnvOutput {
return nil
}

if routeToApp.AWSCredentialProcessCredentials != "" {
return awsNamedProfileLoginTemplate.Execute(output, map[string]string{
"awsAppName": app.GetName(),
Expand Down
Loading
Loading