diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2c31d0ef7ec92..860aeefeaecf9 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -63,7 +63,6 @@ jobs: - 'docs/pages/admin-guides/**' - 'docs/pages/enroll-resources/**' - 'docs/pages/reference/operator-resources/**' - - 'docs/pages/reference/terraform-provider.mdx' - 'docs/pages/reference/terraform-provider/**' - 'examples/chart/teleport-cluster/charts/teleport-operator/operator-crds' diff --git a/api/client/client.go b/api/client/client.go index ee1ea80087791..5f24aa66b9fc3 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -664,6 +664,9 @@ type Config struct { // MFAPromptConstructor is used to create MFA prompts when needed. // If nil, the client will not prompt for MFA. MFAPromptConstructor mfa.PromptConstructor + // SSOMFACeremonyConstructor is used to handle SSO MFA when needed. + // If nil, the client will not prompt for MFA. + SSOMFACeremonyConstructor mfa.SSOMFACeremonyConstructor } // CheckAndSetDefaults checks and sets default config values. @@ -730,6 +733,11 @@ func (c *Client) SetMFAPromptConstructor(pc mfa.PromptConstructor) { c.c.MFAPromptConstructor = pc } +// SetSSOMFACeremonyConstructor sets the SSO MFA ceremony constructor for this client. +func (c *Client) SetSSOMFACeremonyConstructor(scc mfa.SSOMFACeremonyConstructor) { + c.c.SSOMFACeremonyConstructor = scc +} + // Close closes the Client connection to the auth server. func (c *Client) Close() error { if c.setClosed() && c.conn != nil { diff --git a/api/client/mfa.go b/api/client/mfa.go index beba5b20c79dd..8db9af2b318f0 100644 --- a/api/client/mfa.go +++ b/api/client/mfa.go @@ -30,6 +30,7 @@ func (c *Client) PerformMFACeremony(ctx context.Context, challengeRequest *proto mfaCeremony := &mfa.Ceremony{ CreateAuthenticateChallenge: c.CreateAuthenticateChallenge, PromptConstructor: c.c.MFAPromptConstructor, + SSOMFACeremonyConstructor: c.c.SSOMFACeremonyConstructor, } return mfaCeremony.Run(ctx, challengeRequest, promptOpts...) } diff --git a/api/mfa/ceremony.go b/api/mfa/ceremony.go index c542ba36c42b8..3b28162e62164 100644 --- a/api/mfa/ceremony.go +++ b/api/mfa/ceremony.go @@ -32,8 +32,21 @@ type Ceremony struct { CreateAuthenticateChallenge CreateAuthenticateChallengeFunc // PromptConstructor creates a prompt to prompt the user to solve an authentication challenge. PromptConstructor PromptConstructor + // SSOMFACeremonyConstructor is an optional SSO MFA ceremony constructor. If provided, + // the MFA ceremony will also attempt to retrieve an SSO MFA challenge. + SSOMFACeremonyConstructor SSOMFACeremonyConstructor } +// SSOMFACeremony is an SSO MFA ceremony. +type SSOMFACeremony interface { + GetClientCallbackURL() string + Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) + Close() +} + +// SSOMFACeremonyConstructor constructs a new SSO MFA ceremony. +type SSOMFACeremonyConstructor func(ctx context.Context) (SSOMFACeremony, error) + // CreateAuthenticateChallengeFunc is a function that creates an authentication challenge. type CreateAuthenticateChallengeFunc func(ctx context.Context, req *proto.CreateAuthenticateChallengeRequest) (*proto.MFAAuthenticateChallenge, error) @@ -54,6 +67,19 @@ func (c *Ceremony) Run(ctx context.Context, req *proto.CreateAuthenticateChallen return nil, trace.BadParameter("mfa challenge scope must be specified") } + // If available, prepare an SSO MFA ceremony and set the client redirect URL in the challenge + // request to request an SSO challenge in addition to other challenges. + if c.SSOMFACeremonyConstructor != nil { + ssoMFACeremony, err := c.SSOMFACeremonyConstructor(ctx) + if err != nil { + return nil, trace.Wrap(err, "failed to handle SSO MFA ceremony") + } + defer ssoMFACeremony.Close() + + req.SSOClientRedirectURL = ssoMFACeremony.GetClientCallbackURL() + promptOpts = append(promptOpts, withSSOMFACeremony(ssoMFACeremony)) + } + chal, err := c.CreateAuthenticateChallenge(ctx, req) if err != nil { // CreateAuthenticateChallenge returns a bad parameter error when the client diff --git a/api/mfa/ceremony_test.go b/api/mfa/ceremony_test.go index 7d94fd4de5327..d29b03a22487e 100644 --- a/api/mfa/ceremony_test.go +++ b/api/mfa/ceremony_test.go @@ -23,13 +23,14 @@ import ( "github.com/gravitational/trace" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api/client/proto" mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1" "github.com/gravitational/teleport/api/mfa" ) -func TestPerformMFACeremony(t *testing.T) { +func TestMFACeremony(t *testing.T) { t.Parallel() ctx := context.Background() @@ -128,3 +129,77 @@ func TestPerformMFACeremony(t *testing.T) { }) } } + +func TestMFACeremony_SSO(t *testing.T) { + t.Parallel() + ctx := context.Background() + + testMFAChallenge := &proto.MFAAuthenticateChallenge{ + SSOChallenge: &proto.SSOChallenge{ + RedirectUrl: "redirect", + RequestId: "request-id", + }, + } + testMFAResponse := &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_SSO{ + SSO: &proto.SSOResponse{ + Token: "token", + RequestId: "request-id", + }, + }, + } + + ssoMFACeremony := &mfa.Ceremony{ + CreateAuthenticateChallenge: func(ctx context.Context, req *proto.CreateAuthenticateChallengeRequest) (*proto.MFAAuthenticateChallenge, error) { + return testMFAChallenge, nil + }, + PromptConstructor: func(opts ...mfa.PromptOpt) mfa.Prompt { + cfg := new(mfa.PromptConfig) + for _, opt := range opts { + opt(cfg) + } + + return mfa.PromptFunc(func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) { + if cfg.SSOMFACeremony == nil { + return nil, trace.BadParameter("expected sso mfa ceremony") + } + + return cfg.SSOMFACeremony.Run(ctx, chal) + }) + }, + SSOMFACeremonyConstructor: func(ctx context.Context) (mfa.SSOMFACeremony, error) { + return &mockSSOMFACeremony{ + clientCallbackURL: "client-redirect", + prompt: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) { + return testMFAResponse, nil + }, + }, nil + }, + } + + resp, err := ssoMFACeremony.Run(ctx, &proto.CreateAuthenticateChallengeRequest{ + ChallengeExtensions: &mfav1.ChallengeExtensions{ + Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_ADMIN_ACTION, + }, + MFARequiredCheck: &proto.IsMFARequiredRequest{}, + }) + require.NoError(t, err) + require.Equal(t, testMFAResponse, resp) +} + +type mockSSOMFACeremony struct { + clientCallbackURL string + prompt mfa.PromptFunc +} + +// GetClientCallbackURL returns the client callback URL. +func (m *mockSSOMFACeremony) GetClientCallbackURL() string { + return m.clientCallbackURL +} + +// Run the SSO MFA ceremony. +func (m *mockSSOMFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) { + return m.prompt(ctx, chal) +} + +func (m *mockSSOMFACeremony) Close() {} diff --git a/api/mfa/prompt.go b/api/mfa/prompt.go index 1d5c0673d8c1d..9efd9fcb1fe44 100644 --- a/api/mfa/prompt.go +++ b/api/mfa/prompt.go @@ -54,6 +54,8 @@ type PromptConfig struct { // Extensions are the challenge extensions used to create the prompt's challenge. // Used to enrich certain prompts. Extensions *mfav1.ChallengeExtensions + // SSOMFACeremony is an SSO MFA ceremony. + SSOMFACeremony SSOMFACeremony } // DeviceDescriptor is a descriptor for a device, such as "registered". @@ -117,3 +119,10 @@ func WithPromptChallengeExtensions(exts *mfav1.ChallengeExtensions) PromptOpt { cfg.Extensions = exts } } + +// withSSOMFACeremony sets the SSO MFA ceremony for the MFA prompt. +func withSSOMFACeremony(ssoMFACeremony SSOMFACeremony) PromptOpt { + return func(cfg *PromptConfig) { + cfg.SSOMFACeremony = ssoMFACeremony + } +} diff --git a/docs/pages/admin-guides/infrastructure-as-code/managing-resources/import-existing-resources.mdx b/docs/pages/admin-guides/infrastructure-as-code/managing-resources/import-existing-resources.mdx index d5329defb9feb..4e3a186d6b0c4 100644 --- a/docs/pages/admin-guides/infrastructure-as-code/managing-resources/import-existing-resources.mdx +++ b/docs/pages/admin-guides/infrastructure-as-code/managing-resources/import-existing-resources.mdx @@ -83,5 +83,6 @@ cluster configuration matches your expectations. - Follow [the user and role IaC guide](user-and-role.mdx) to use the Terraform Provider to create Teleport users and grant them roles. - Explore the full list of supported [Terraform provider - resources](../../../reference/terraform-provider.mdx). -- See [the list of supported Teleport Terraform setups](../terraform-provider/terraform-provider.mdx): + resources](../../../reference/terraform-provider/terraform-provider.mdx). +- See [the list of supported Teleport Terraform + setups](../terraform-provider/terraform-provider.mdx): diff --git a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/dedicated-server.mdx b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/dedicated-server.mdx index 3afb79ea87035..683f4f3082bf8 100644 --- a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/dedicated-server.mdx +++ b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/dedicated-server.mdx @@ -159,7 +159,7 @@ $ tctl get role/terraform-test ## Next steps - Explore the - [Terraform provider resource reference](../../../reference/terraform-provider.mdx) + [Terraform provider resource reference](../../../reference/terraform-provider/terraform-provider.mdx) to discover what can be configured with the Teleport Terraform provider. - Read the [tbot configuration reference](../../../reference/machine-id/configuration.mdx) to explore all the available `tbot` configuration options. diff --git a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/local.mdx b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/local.mdx index 59c49dd2e831f..d6912c912c32e 100644 --- a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/local.mdx +++ b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/local.mdx @@ -159,8 +159,10 @@ Do not forget to obtain new temporary credentials every hour by re-running `eval - Follow [the user and role IaC guide](../managing-resources/user-and-role.mdx) to use the Terraform Provider to create Teleport users and grant them roles. - Consult the list of Terraform-supported - resources [in the Terraform reference](../../../reference/terraform-provider.mdx). -- Once you have working Terraform code that configures your Teleport cluster, you might want to run it in the CI or - from a bastion instead of running it locally. To do this, please follow the dedicated guides: + resources [in the Terraform + reference](../../../reference/terraform-provider/terraform-provider.mdx). +- Once you have working Terraform code that configures your Teleport cluster, + you might want to run it in the CI or from a bastion instead of running it + locally. To do this, please follow the dedicated guides: - [Run the Terraform Provider in CI or cloud VMs](./ci-or-cloud.mdx) - [Run the Terraform Provider on a dedicated server](./dedicated-server.mdx) diff --git a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/long-lived-credentials.mdx b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/long-lived-credentials.mdx index ee1e9e3ed3cc5..9e8d15e9d372b 100644 --- a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/long-lived-credentials.mdx +++ b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/long-lived-credentials.mdx @@ -199,6 +199,9 @@ To apply the configuration: ## Next steps -- Explore the full list of supported [Terraform provider resources](../../../reference/terraform-provider.mdx). -- Learn [how to manage users and roles with IaC](../managing-resources/user-and-role.mdx) -- Read more about [impersonation](../../access-controls/guides/impersonation.mdx). +- Explore the full list of supported [Terraform provider + resources](../../../reference/terraform-provider/terraform-provider.mdx). +- Learn [how to manage users and roles with + IaC](../managing-resources/user-and-role.mdx) +- Read more about + [impersonation](../../access-controls/guides/impersonation.mdx). diff --git a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/spacelift.mdx b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/spacelift.mdx index 976b95f8b2306..249a954eee4a3 100644 --- a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/spacelift.mdx +++ b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/spacelift.mdx @@ -260,8 +260,8 @@ $ tctl get users/terraform-test - Now that you know how to manage Teleport configuration resources with Terraform and Spacelift, read the [Terraform resource - reference](../../../reference/terraform-provider.mdx) so you can flesh out your - configuration. + reference](../../../reference/terraform-provider/terraform-provider.mdx) so + you can flesh out your configuration. - To find out more about Spacelift's OIDC implementation, which Machine ID uses to authenticate to your Teleport cluster, read [the Spacelift documentation](https://docs.spacelift.io/integrations/cloud-providers/oidc/). diff --git a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx index e19b9a49b0fc9..5a7a41505ae19 100644 --- a/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx +++ b/docs/pages/admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx @@ -28,8 +28,8 @@ is executed. You must pick the correct guide for your setup: Once you have a functional Teleport Terraform provider, you will want to configure your resources with it. -You can find the list of supported resources and their fields is -available [in the Terraform reference](../../../reference/terraform-provider.mdx). +The list of supported resources and their fields is available [in the Terraform +reference](../../../reference/terraform-provider/terraform-provider.mdx). Some resources have their dedicated Infrastructure-as-Code (IaC) step-by step guides such as: - [Managing Users And Roles With IaC](../managing-resources/user-and-role.mdx) diff --git a/docs/pages/admin-guides/infrastructure-as-code/terraform-starter/enroll-resources.mdx b/docs/pages/admin-guides/infrastructure-as-code/terraform-starter/enroll-resources.mdx index d4de6522c848a..5a2f34e326db2 100644 --- a/docs/pages/admin-guides/infrastructure-as-code/terraform-starter/enroll-resources.mdx +++ b/docs/pages/admin-guides/infrastructure-as-code/terraform-starter/enroll-resources.mdx @@ -623,7 +623,7 @@ edit your Terraform module to: 1. **Change the userdata script** to enable additional Agent services additional infrastructure resources for your Agents to proxy. 1. **Deploy dynamic resources:** Consult the [Terraform provider - reference](../../../reference/terraform-provider.mdx) for Terraform resources - that you can apply in order to enroll dynamic resources in your - infrastructure. + reference](../../../reference/terraform-provider/terraform-provider.mdx) for + Terraform resources that you can apply in order to enroll dynamic resources + in your infrastructure. diff --git a/docs/pages/admin-guides/infrastructure-as-code/terraform-starter/rbac.mdx b/docs/pages/admin-guides/infrastructure-as-code/terraform-starter/rbac.mdx index 3699876228ef1..865192382bc8a 100644 --- a/docs/pages/admin-guides/infrastructure-as-code/terraform-starter/rbac.mdx +++ b/docs/pages/admin-guides/infrastructure-as-code/terraform-starter/rbac.mdx @@ -563,4 +563,4 @@ troubleshoot the single sign-on provider. Now that you have configured RBAC in your Terraform demo cluster, fine-tune your setup by reading the comprehensive [Terraform provider -reference](../../../reference/terraform-provider.mdx). +reference](../../../reference/terraform-provider/terraform-provider.mdx). diff --git a/docs/pages/reference/terraform-provider/data-sources.mdx b/docs/pages/reference/terraform-provider/data-sources.mdx deleted file mode 100644 index 6c7f82c16279a..0000000000000 --- a/docs/pages/reference/terraform-provider/data-sources.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "Terraform data-sources index" -description: "Index of all the data-sources supported by the Teleport Terraform Provider" ---- - -{/*Auto-generated file. Do not edit.*/} -{/*To regenerate, navigate to integrations/terraform and run `make docs`.*/} - -{/* - This file will be renamed data-sources.mdx during build time. - The template name is reserved by tfplugindocs so we suffix with -index. -*/} - -The Teleport Terraform provider supports the following data-sources: - - - [`teleport_access_list`](./data-sources/access_list.mdx) - - [`teleport_access_monitoring_rule`](./data-sources/access_monitoring_rule.mdx) - - [`teleport_app`](./data-sources/app.mdx) - - [`teleport_auth_preference`](./data-sources/auth_preference.mdx) - - [`teleport_cluster_maintenance_config`](./data-sources/cluster_maintenance_config.mdx) - - [`teleport_cluster_networking_config`](./data-sources/cluster_networking_config.mdx) - - [`teleport_database`](./data-sources/database.mdx) - - [`teleport_github_connector`](./data-sources/github_connector.mdx) - - [`teleport_installer`](./data-sources/installer.mdx) - - [`teleport_login_rule`](./data-sources/login_rule.mdx) - - [`teleport_oidc_connector`](./data-sources/oidc_connector.mdx) - - [`teleport_okta_import_rule`](./data-sources/okta_import_rule.mdx) - - [`teleport_provision_token`](./data-sources/provision_token.mdx) - - [`teleport_role`](./data-sources/role.mdx) - - [`teleport_saml_connector`](./data-sources/saml_connector.mdx) - - [`teleport_session_recording_config`](./data-sources/session_recording_config.mdx) - - [`teleport_static_host_user`](./data-sources/static_host_user.mdx) - - [`teleport_trusted_cluster`](./data-sources/trusted_cluster.mdx) - - [`teleport_trusted_device`](./data-sources/trusted_device.mdx) - - [`teleport_user`](./data-sources/user.mdx) diff --git a/docs/pages/reference/terraform-provider/data-sources/data-sources.mdx b/docs/pages/reference/terraform-provider/data-sources/data-sources.mdx new file mode 100644 index 0000000000000..047a8a04a630b --- /dev/null +++ b/docs/pages/reference/terraform-provider/data-sources/data-sources.mdx @@ -0,0 +1,35 @@ +--- +title: "Terraform data-sources index" +description: "Index of all the data-sources supported by the Teleport Terraform Provider" +--- + +{/*Auto-generated file. Do not edit.*/} +{/*To regenerate, navigate to integrations/terraform and run `make docs`.*/} + +{/* + This file will be renamed data-sources.mdx during build time. + The template name is reserved by tfplugindocs so we suffix with -index. +*/} + +The Teleport Terraform provider supports the following data-sources: + + - [`teleport_access_list`](./access_list.mdx) + - [`teleport_access_monitoring_rule`](./access_monitoring_rule.mdx) + - [`teleport_app`](./app.mdx) + - [`teleport_auth_preference`](./auth_preference.mdx) + - [`teleport_cluster_maintenance_config`](./cluster_maintenance_config.mdx) + - [`teleport_cluster_networking_config`](./cluster_networking_config.mdx) + - [`teleport_database`](./database.mdx) + - [`teleport_github_connector`](./github_connector.mdx) + - [`teleport_installer`](./installer.mdx) + - [`teleport_login_rule`](./login_rule.mdx) + - [`teleport_oidc_connector`](./oidc_connector.mdx) + - [`teleport_okta_import_rule`](./okta_import_rule.mdx) + - [`teleport_provision_token`](./provision_token.mdx) + - [`teleport_role`](./role.mdx) + - [`teleport_saml_connector`](./saml_connector.mdx) + - [`teleport_session_recording_config`](./session_recording_config.mdx) + - [`teleport_static_host_user`](./static_host_user.mdx) + - [`teleport_trusted_cluster`](./trusted_cluster.mdx) + - [`teleport_trusted_device`](./trusted_device.mdx) + - [`teleport_user`](./user.mdx) diff --git a/docs/pages/reference/terraform-provider/resources.mdx b/docs/pages/reference/terraform-provider/resources.mdx deleted file mode 100644 index dd2640e926d22..0000000000000 --- a/docs/pages/reference/terraform-provider/resources.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: "Terraform resources index" -description: "Index of all the datasources supported by the Teleport Terraform Provider" ---- - -{/*Auto-generated file. Do not edit.*/} -{/*To regenerate, navigate to integrations/terraform and run `make docs`.*/} - -{/* - This file will be renamed data-sources.mdx during build time. - The template name is reserved by tfplugindocs so we suffix with -index. -*/} - -The Teleport Terraform provider supports the following resources: - - - [`teleport_access_list`](./resources/access_list.mdx) - - [`teleport_access_monitoring_rule`](./resources/access_monitoring_rule.mdx) - - [`teleport_app`](./resources/app.mdx) - - [`teleport_auth_preference`](./resources/auth_preference.mdx) - - [`teleport_bot`](./resources/bot.mdx) - - [`teleport_cluster_maintenance_config`](./resources/cluster_maintenance_config.mdx) - - [`teleport_cluster_networking_config`](./resources/cluster_networking_config.mdx) - - [`teleport_database`](./resources/database.mdx) - - [`teleport_github_connector`](./resources/github_connector.mdx) - - [`teleport_installer`](./resources/installer.mdx) - - [`teleport_login_rule`](./resources/login_rule.mdx) - - [`teleport_oidc_connector`](./resources/oidc_connector.mdx) - - [`teleport_okta_import_rule`](./resources/okta_import_rule.mdx) - - [`teleport_provision_token`](./resources/provision_token.mdx) - - [`teleport_role`](./resources/role.mdx) - - [`teleport_saml_connector`](./resources/saml_connector.mdx) - - [`teleport_server`](./resources/server.mdx) - - [`teleport_session_recording_config`](./resources/session_recording_config.mdx) - - [`teleport_static_host_user`](./resources/static_host_user.mdx) - - [`teleport_trusted_cluster`](./resources/trusted_cluster.mdx) - - [`teleport_trusted_device`](./resources/trusted_device.mdx) - - [`teleport_user`](./resources/user.mdx) diff --git a/docs/pages/reference/terraform-provider/resources/resources.mdx b/docs/pages/reference/terraform-provider/resources/resources.mdx new file mode 100644 index 0000000000000..ac150d8a43048 --- /dev/null +++ b/docs/pages/reference/terraform-provider/resources/resources.mdx @@ -0,0 +1,37 @@ +--- +title: "Terraform resources index" +description: "Index of all the datasources supported by the Teleport Terraform Provider" +--- + +{/*Auto-generated file. Do not edit.*/} +{/*To regenerate, navigate to integrations/terraform and run `make docs`.*/} + +{/* + This file will be renamed data-sources.mdx during build time. + The template name is reserved by tfplugindocs so we suffix with -index. +*/} + +The Teleport Terraform provider supports the following resources: + + - [`teleport_access_list`](./access_list.mdx) + - [`teleport_access_monitoring_rule`](./access_monitoring_rule.mdx) + - [`teleport_app`](./app.mdx) + - [`teleport_auth_preference`](./auth_preference.mdx) + - [`teleport_bot`](./bot.mdx) + - [`teleport_cluster_maintenance_config`](./cluster_maintenance_config.mdx) + - [`teleport_cluster_networking_config`](./cluster_networking_config.mdx) + - [`teleport_database`](./database.mdx) + - [`teleport_github_connector`](./github_connector.mdx) + - [`teleport_installer`](./installer.mdx) + - [`teleport_login_rule`](./login_rule.mdx) + - [`teleport_oidc_connector`](./oidc_connector.mdx) + - [`teleport_okta_import_rule`](./okta_import_rule.mdx) + - [`teleport_provision_token`](./provision_token.mdx) + - [`teleport_role`](./role.mdx) + - [`teleport_saml_connector`](./saml_connector.mdx) + - [`teleport_server`](./server.mdx) + - [`teleport_session_recording_config`](./session_recording_config.mdx) + - [`teleport_static_host_user`](./static_host_user.mdx) + - [`teleport_trusted_cluster`](./trusted_cluster.mdx) + - [`teleport_trusted_device`](./trusted_device.mdx) + - [`teleport_user`](./user.mdx) diff --git a/docs/pages/reference/terraform-provider.mdx b/docs/pages/reference/terraform-provider/terraform-provider.mdx similarity index 84% rename from docs/pages/reference/terraform-provider.mdx rename to docs/pages/reference/terraform-provider/terraform-provider.mdx index 0c959e49ff397..d1a84f5b694aa 100644 --- a/docs/pages/reference/terraform-provider.mdx +++ b/docs/pages/reference/terraform-provider/terraform-provider.mdx @@ -14,10 +14,10 @@ It lists all the supported resources and their fields. To get started with the Terraform provider, you must start with [the installation -guide](../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx). +guide](../../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx). Once you got a working provider, we recommend you to follow the ["Managing users and roles with IaC"]( -../admin-guides/infrastructure-as-code/managing-resources/user-and-role.mdx) guide. +../../admin-guides/infrastructure-as-code/managing-resources/user-and-role.mdx) guide. The provider exposes Teleport resources both as Terraform data-sources and Terraform resources. @@ -27,8 +27,8 @@ to create resources in Teleport. {/* Note: the awkward `resource-index` file names are here because `data-sources` is reserved by the generator for the catch-all resource template */} -- [list of supported resources](./terraform-provider/resources.mdx) -- [list of supported data-sources](./terraform-provider/data-sources.mdx) +- [list of supported resources](./resources/resources.mdx) +- [list of supported data-sources](./data-sources/data-sources.mdx) ## Example Usage @@ -81,7 +81,7 @@ provider "teleport" { This section lists the different ways of passing credentials to the Terraform provider. You can find which method fits your use case in the [Teleport Terraform provider setup -page](../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx) +page](../../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx) ### With an identity file @@ -108,16 +108,16 @@ Detected security key tap ``` You can find more information in -the ["Run the Terraform provider locally" guide](../admin-guides/infrastructure-as-code/terraform-provider/local.mdx) +the ["Run the Terraform provider locally" guide](../../admin-guides/infrastructure-as-code/terraform-provider/local.mdx) #### Obtaining an identity file via `tbot` -`tbot` relies on [MachineID](../enroll-resources/machine-id/introduction.mdx) to obtain and automatically renew +`tbot` relies on [MachineID](../../enroll-resources/machine-id/introduction.mdx) to obtain and automatically renew short-lived credentials. Such credentials are harder to exfiltrate, and you can control more precisely who has access to which roles (e.g. you can allow only GitHub Actions pipelines targeting the `prod` environment to get certificates). You can follow [the Terraform Provider -guide](../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx) to setup `tbot` +guide](../../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx) to setup `tbot` and have Terraform use its identity. #### Obtaining an identity file via `tctl auth sign` @@ -132,7 +132,7 @@ This auth method has the following limitations: - Such credentials are high-privileged and long-lived. They must be protected and rotated. - This auth method does not work against Teleport clusters with MFA set to `webauthn`. On such clusters, Teleport will reject any long-lived certificate and require - [an additional MFA challenge for administrative actions](../admin-guides/access-controls/guides/mfa-for-admin-actions.mdx). + [an additional MFA challenge for administrative actions](../../admin-guides/access-controls/guides/mfa-for-admin-actions.mdx). ### With a token (native MachineID) @@ -140,11 +140,11 @@ Starting with 16.2, the Teleport Terraform provider can natively use MachineID ( cluster. The Terraform Provider will rely on its runtime (AWS, GCP, Kubernetes, CI/CD system) to prove its identity to Teleport. -You can use any [delegated join method](./join-methods.mdx#delegated-join-methods) by setting +You can use any [delegated join method](../join-methods.mdx#delegated-join-methods) by setting both `join_method` and `join_token` in the provider configuration. This setup is described in more details in -the ["Run the Teleport Terraform provider in CI or Cloud" guide](../admin-guides/infrastructure-as-code/terraform-provider/ci-or-cloud.mdx). +the ["Run the Teleport Terraform provider in CI or Cloud" guide](../../admin-guides/infrastructure-as-code/terraform-provider/ci-or-cloud.mdx). ### With key, certificate, and CA certificate @@ -160,7 +160,7 @@ This auth method has the following limitations: - Such credentials are high-privileged and long-lived. They must be protected and rotated. - This auth method does not work against Teleport clusters with MFA set to `webauthn`. On such clusters, Teleport will reject any long-lived certificate and require - [an additional MFA challenge for administrative actions](../admin-guides/access-controls/guides/mfa-for-admin-actions.mdx). + [an additional MFA challenge for administrative actions](../../admin-guides/access-controls/guides/mfa-for-admin-actions.mdx). {/* schema generated by tfplugindocs */} ## Schema @@ -175,8 +175,8 @@ This auth method has the following limitations: - `identity_file` (String, Sensitive) Teleport identity file content. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE`. - `identity_file_base64` (String, Sensitive) Teleport identity file content base64 encoded. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE_BASE64`. - `identity_file_path` (String) Teleport identity file path. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE_PATH`. -- `join_method` (String) Enables the native Terraform MachineID support. When set, Terraform uses MachineID to securely join the Teleport cluster and obtain credentials. See [the join method reference](./join-methods.mdx) for possible values, you must use [a delegated join method](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `TF_TELEPORT_JOIN_METHOD`. -- `join_token` (String) Name of the token used for the native MachineID joining. This value is not sensitive for [delegated join methods](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `TF_TELEPORT_JOIN_TOKEN`. +- `join_method` (String) Enables the native Terraform MachineID support. When set, Terraform uses MachineID to securely join the Teleport cluster and obtain credentials. See [the join method reference](../join-methods.mdx) for possible values. You must use [a delegated join method](../join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `TF_TELEPORT_JOIN_METHOD`. +- `join_token` (String) Name of the token used for the native MachineID joining. This value is not sensitive for [delegated join methods](../join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `TF_TELEPORT_JOIN_TOKEN`. - `key_base64` (String, Sensitive) Base64 encoded TLS auth key. This can also be set with the environment variable `TF_TELEPORT_KEY_BASE64`. - `key_path` (String) Path to Teleport auth key file. This can also be set with the environment variable `TF_TELEPORT_KEY`. - `profile_dir` (String) Teleport profile path. This can also be set with the environment variable `TF_TELEPORT_PROFILE_PATH`. diff --git a/integrations/terraform/gen/docs.sh b/integrations/terraform/gen/docs.sh index eba48091d57ce..f6570db4b41a3 100755 --- a/integrations/terraform/gen/docs.sh +++ b/integrations/terraform/gen/docs.sh @@ -67,15 +67,15 @@ info "Converting .md files to .mdx" cd "$TMPDIR/docs" find . -iname '*.md' -type f -exec sh -c 'i="$1"; mv "$i" "${i%.md}.mdx"' shell {} \; # renaming the resources and data-sources indexes because the names were reserved by the generator -mv "$TMPDIR/docs/resources-index.mdx" "$TMPDIR/docs/resources.mdx" -mv "$TMPDIR/docs/data-sources-index.mdx" "$TMPDIR/docs/data-sources.mdx" +mv "$TMPDIR/docs/resources-index.mdx" "$TMPDIR/docs/resources/resources.mdx" +mv "$TMPDIR/docs/data-sources-index.mdx" "$TMPDIR/docs/data-sources/data-sources.mdx" info "Copying generated documentation into the teleport docs directory" # Removing the apex terraform.mdx -rm -rf "$DOCSDIR" "$DOCSDIR.mdx" +rm -rf "$DOCSDIR" "$DOCSDIR/terraform-provider.mdx" cp -r "$TMPDIR/docs" "$DOCSDIR" # unpacking the index to the apex terraform.mdx -mv "$DOCSDIR/index.mdx" "$DOCSDIR.mdx" +mv "$DOCSDIR/index.mdx" "$DOCSDIR/terraform-provider.mdx" -info "TF documentation successfully generated" \ No newline at end of file +info "TF documentation successfully generated" diff --git a/integrations/terraform/provider/provider.go b/integrations/terraform/provider/provider.go index dfc0d9b9a14c3..1f1a923a60c91 100644 --- a/integrations/terraform/provider/provider.go +++ b/integrations/terraform/provider/provider.go @@ -247,13 +247,13 @@ func (p *Provider) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) Type: types.StringType, Sensitive: false, Optional: true, - Description: fmt.Sprintf("Enables the native Terraform MachineID support. When set, Terraform uses MachineID to securely join the Teleport cluster and obtain credentials. See [the join method reference](./join-methods.mdx) for possible values, you must use [a delegated join method](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformJoinMethod), + Description: fmt.Sprintf("Enables the native Terraform MachineID support. When set, Terraform uses MachineID to securely join the Teleport cluster and obtain credentials. See [the join method reference](../join-methods.mdx) for possible values. You must use [a delegated join method](../join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformJoinMethod), }, attributeTerraformJoinToken: { Type: types.StringType, Sensitive: false, Optional: true, - Description: fmt.Sprintf("Name of the token used for the native MachineID joining. This value is not sensitive for [delegated join methods](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformJoinToken), + Description: fmt.Sprintf("Name of the token used for the native MachineID joining. This value is not sensitive for [delegated join methods](../join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformJoinToken), }, attributeTerraformJoinAudienceTag: { Type: types.StringType, diff --git a/integrations/terraform/templates/data-sources-index.mdx.tmpl b/integrations/terraform/templates/data-sources-index.mdx.tmpl index c4c7b90af7525..9eac755076952 100644 --- a/integrations/terraform/templates/data-sources-index.mdx.tmpl +++ b/integrations/terraform/templates/data-sources-index.mdx.tmpl @@ -13,5 +13,5 @@ description: "Index of all the data-sources supported by the Teleport Terraform The Teleport Terraform provider supports the following data-sources: {{ range $key, $value := .DataSourceFiles }} - - [`{{$key}}`](./data-sources/{{$value}}.mdx) + - [`{{$key}}`](./{{$value}}.mdx) {{- end }} diff --git a/integrations/terraform/templates/index.md.tmpl b/integrations/terraform/templates/index.md.tmpl index 15bc1c7c81fa5..488665209f78a 100644 --- a/integrations/terraform/templates/index.md.tmpl +++ b/integrations/terraform/templates/index.md.tmpl @@ -14,10 +14,10 @@ It lists all the supported resources and their fields. To get started with the Terraform provider, you must start with [the installation -guide](../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx). +guide](../../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx). Once you got a working provider, we recommend you to follow the ["Managing users and roles with IaC"]( -../admin-guides/infrastructure-as-code/managing-resources/user-and-role.mdx) guide. +../../admin-guides/infrastructure-as-code/managing-resources/user-and-role.mdx) guide. The provider exposes Teleport resources both as Terraform data-sources and Terraform resources. @@ -27,8 +27,8 @@ to create resources in Teleport. {/* Note: the awkward `resource-index` file names are here because `data-sources` is reserved by the generator for the catch-all resource template */} -- [list of supported resources](./terraform-provider/resources.mdx) -- [list of supported data-sources](./terraform-provider/data-sources.mdx) +- [list of supported resources](./resources/resources.mdx) +- [list of supported data-sources](./data-sources/data-sources.mdx) ## Example Usage @@ -81,7 +81,7 @@ provider "teleport" { This section lists the different ways of passing credentials to the Terraform provider. You can find which method fits your use case in the [Teleport Terraform provider setup -page](../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx) +page](../../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx) ### With an identity file @@ -108,16 +108,16 @@ Detected security key tap ``` You can find more information in -the ["Run the Terraform provider locally" guide](../admin-guides/infrastructure-as-code/terraform-provider/local.mdx) +the ["Run the Terraform provider locally" guide](../../admin-guides/infrastructure-as-code/terraform-provider/local.mdx) #### Obtaining an identity file via `tbot` -`tbot` relies on [MachineID](../enroll-resources/machine-id/introduction.mdx) to obtain and automatically renew +`tbot` relies on [MachineID](../../enroll-resources/machine-id/introduction.mdx) to obtain and automatically renew short-lived credentials. Such credentials are harder to exfiltrate, and you can control more precisely who has access to which roles (e.g. you can allow only GitHub Actions pipelines targeting the `prod` environment to get certificates). You can follow [the Terraform Provider -guide](../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx) to setup `tbot` +guide](../../admin-guides/infrastructure-as-code/terraform-provider/terraform-provider.mdx) to setup `tbot` and have Terraform use its identity. #### Obtaining an identity file via `tctl auth sign` @@ -132,7 +132,7 @@ This auth method has the following limitations: - Such credentials are high-privileged and long-lived. They must be protected and rotated. - This auth method does not work against Teleport clusters with MFA set to `webauthn`. On such clusters, Teleport will reject any long-lived certificate and require - [an additional MFA challenge for administrative actions](../admin-guides/access-controls/guides/mfa-for-admin-actions.mdx). + [an additional MFA challenge for administrative actions](../../admin-guides/access-controls/guides/mfa-for-admin-actions.mdx). ### With a token (native MachineID) @@ -140,11 +140,11 @@ Starting with 16.2, the Teleport Terraform provider can natively use MachineID ( cluster. The Terraform Provider will rely on its runtime (AWS, GCP, Kubernetes, CI/CD system) to prove its identity to Teleport. -You can use any [delegated join method](./join-methods.mdx#delegated-join-methods) by setting +You can use any [delegated join method](../join-methods.mdx#delegated-join-methods) by setting both `join_method` and `join_token` in the provider configuration. This setup is described in more details in -the ["Run the Teleport Terraform provider in CI or Cloud" guide](../admin-guides/infrastructure-as-code/terraform-provider/ci-or-cloud.mdx). +the ["Run the Teleport Terraform provider in CI or Cloud" guide](../../admin-guides/infrastructure-as-code/terraform-provider/ci-or-cloud.mdx). ### With key, certificate, and CA certificate @@ -160,7 +160,7 @@ This auth method has the following limitations: - Such credentials are high-privileged and long-lived. They must be protected and rotated. - This auth method does not work against Teleport clusters with MFA set to `webauthn`. On such clusters, Teleport will reject any long-lived certificate and require - [an additional MFA challenge for administrative actions](../admin-guides/access-controls/guides/mfa-for-admin-actions.mdx). + [an additional MFA challenge for administrative actions](../../admin-guides/access-controls/guides/mfa-for-admin-actions.mdx). {{ .SchemaMarkdown | trimspace }} diff --git a/integrations/terraform/templates/resources-index.mdx.tmpl b/integrations/terraform/templates/resources-index.mdx.tmpl index 42f5821dfbca9..00167441cf03b 100644 --- a/integrations/terraform/templates/resources-index.mdx.tmpl +++ b/integrations/terraform/templates/resources-index.mdx.tmpl @@ -13,5 +13,5 @@ description: "Index of all the datasources supported by the Teleport Terraform P The Teleport Terraform provider supports the following resources: {{ range $key, $value := .ResourceFiles }} - - [`{{$key}}`](./resources/{{$value}}.mdx) + - [`{{$key}}`](./{{$value}}.mdx) {{- end }} diff --git a/lib/client/api.go b/lib/client/api.go index 0a7b35dbd5e51..d087fb02d1e34 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -360,6 +360,9 @@ type Config struct { // authenticators, such as remote hosts or virtual machines. PreferOTP bool + // PreferSSO prefers SSO in favor of other MFA methods. + PreferSSO bool + // CheckVersions will check that client version is compatible // with auth server version when connecting. CheckVersions bool @@ -3043,6 +3046,8 @@ func (tc *TeleportClient) ConnectToCluster(ctx context.Context) (_ *ClusterClien return nil, trace.NewAggregate(err, pclt.Close()) } authClientCfg.MFAPromptConstructor = tc.NewMFAPrompt + authClientCfg.SSOMFACeremonyConstructor = tc.NewSSOMFACeremony + authClient, err := authclient.NewClient(authClientCfg) if err != nil { return nil, trace.NewAggregate(err, pclt.Close()) @@ -5062,9 +5067,10 @@ func (tc *TeleportClient) NewKubernetesServiceClient(ctx context.Context, cluste Credentials: []client.Credentials{ client.LoadTLS(tlsConfig), }, - ALPNConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired, - InsecureAddressDiscovery: tc.InsecureSkipVerify, - MFAPromptConstructor: tc.NewMFAPrompt, + ALPNConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired, + InsecureAddressDiscovery: tc.InsecureSkipVerify, + MFAPromptConstructor: tc.NewMFAPrompt, + SSOMFACeremonyConstructor: tc.NewSSOMFACeremony, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/client/cluster_client_test.go b/lib/client/cluster_client_test.go index 70a9985853ceb..7a90be3f30d80 100644 --- a/lib/client/cluster_client_test.go +++ b/lib/client/cluster_client_test.go @@ -361,8 +361,9 @@ func TestIssueUserCertsWithMFA(t *testing.T) { tc: &TeleportClient{ localAgent: agent, Config: Config{ - SiteName: "test", - Tracer: tracing.NoopTracer("test"), + WebProxyAddr: "proxy.example.com", + SiteName: "test", + Tracer: tracing.NoopTracer("test"), MFAPromptConstructor: func(cfg *libmfa.PromptConfig) mfa.Prompt { return test.prompt }, diff --git a/lib/client/mfa.go b/lib/client/mfa.go index 90c6f975d3a1c..51f2073b71e45 100644 --- a/lib/client/mfa.go +++ b/lib/client/mfa.go @@ -26,6 +26,7 @@ import ( "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/mfa" libmfa "github.com/gravitational/teleport/lib/client/mfa" + "github.com/gravitational/teleport/lib/client/sso" ) // NewMFACeremony returns a new MFA ceremony configured for this client. @@ -33,6 +34,7 @@ func (tc *TeleportClient) NewMFACeremony() *mfa.Ceremony { return &mfa.Ceremony{ CreateAuthenticateChallenge: tc.createAuthenticateChallenge, PromptConstructor: tc.NewMFAPrompt, + SSOMFACeremonyConstructor: tc.NewSSOMFACeremony, } } @@ -61,6 +63,7 @@ func (tc *TeleportClient) NewMFAPrompt(opts ...mfa.PromptOpt) mfa.Prompt { PromptConfig: *cfg, Writer: tc.Stderr, PreferOTP: tc.PreferOTP, + PreferSSO: tc.PreferSSO, AllowStdinHijack: tc.AllowStdinHijack, StdinFunc: tc.StdinFunc, }) @@ -79,5 +82,21 @@ func (tc *TeleportClient) newPromptConfig(opts ...mfa.PromptOpt) *libmfa.PromptC cfg.WebauthnLoginFunc = tc.WebauthnLogin cfg.WebauthnSupported = true } + return cfg } + +// NewSSOMFACeremony creates a new SSO MFA ceremony. +func (tc *TeleportClient) NewSSOMFACeremony(ctx context.Context) (mfa.SSOMFACeremony, error) { + rdConfig, err := tc.ssoRedirectorConfig(ctx, "" /*connectorDisplayName*/) + if err != nil { + return nil, trace.Wrap(err) + } + + rd, err := sso.NewRedirector(rdConfig) + if err != nil { + return nil, trace.Wrap(err) + } + + return sso.NewCLIMFACeremony(rd), nil +} diff --git a/lib/client/mfa/cli.go b/lib/client/mfa/cli.go index 309a8d7000636..400ad7d88e6c6 100644 --- a/lib/client/mfa/cli.go +++ b/lib/client/mfa/cli.go @@ -25,6 +25,7 @@ import ( "log/slog" "os" "runtime" + "strings" "sync" "github.com/gravitational/trace" @@ -38,6 +39,15 @@ import ( "github.com/gravitational/teleport/lib/auth/webauthnwin" ) +const ( + // cliMFATypeOTP is the CLI display name for OTP. + cliMFATypeOTP = "OTP" + // cliMFATypeWebauthn is the CLI display name for Webauthn. + cliMFATypeWebauthn = "WEBAUTHN" + // cliMFATypeSSO is the CLI display name for SSO. + cliMFATypeSSO = "SSO" +) + // CLIPromptConfig contains CLI prompt config options. type CLIPromptConfig struct { PromptConfig @@ -52,6 +62,9 @@ type CLIPromptConfig struct { // PreferOTP favors OTP challenges, if applicable. // Takes precedence over AuthenticatorAttachment settings. PreferOTP bool + // PreferSSO favors SSO challenges, if applicable. + // Takes precedence over AuthenticatorAttachment settings. + PreferSSO bool // StdinFunc allows tests to override prompt.Stdin(). // If nil prompt.Stdin() is used. StdinFunc func() prompt.StdinReader @@ -110,24 +123,51 @@ func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng promptOTP := chal.TOTP != nil promptWebauthn := chal.WebauthnChallenge != nil + promptSSO := chal.SSOChallenge != nil // No prompt to run, no-op. - if !promptOTP && !promptWebauthn { + if !promptOTP && !promptWebauthn && !promptSSO { return &proto.MFAAuthenticateResponse{}, nil } + var availableMethods []string + if promptWebauthn { + availableMethods = append(availableMethods, cliMFATypeWebauthn) + } + if promptSSO { + availableMethods = append(availableMethods, cliMFATypeSSO) + } + if promptOTP { + availableMethods = append(availableMethods, cliMFATypeOTP) + } + // Check off unsupported methods. if promptWebauthn && !c.cfg.WebauthnSupported { promptWebauthn = false slog.DebugContext(ctx, "hardware device MFA not supported by your platform") - if !promptOTP { - return nil, trace.BadParameter("hardware device MFA not supported by your platform, please register an OTP device") - } + } + + if promptSSO && c.cfg.SSOMFACeremony == nil { + promptSSO = false + slog.DebugContext(ctx, "SSO MFA not supported by this client, this is likely a bug") } // Prefer whatever method is requested by the client. - if c.cfg.PreferOTP && promptOTP { - promptWebauthn = false + var chosenMethods []string + var userSpecifiedMethod bool + switch { + case c.cfg.PreferSSO && promptSSO: + chosenMethods = []string{cliMFATypeSSO} + promptWebauthn, promptOTP = false, false + userSpecifiedMethod = true + case c.cfg.PreferOTP && promptOTP: + chosenMethods = []string{cliMFATypeOTP} + promptWebauthn, promptSSO = false, false + userSpecifiedMethod = true + case c.cfg.AuthenticatorAttachment != wancli.AttachmentAuto: + chosenMethods = []string{cliMFATypeWebauthn} + promptSSO, promptOTP = false, false + userSpecifiedMethod = true } // Use stronger auth methods if hijack is not allowed. @@ -135,10 +175,29 @@ func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng promptOTP = false } - // If a specific webauthn attachment was requested, skip OTP. - // Otherwise, allow dual prompt with OTP. - if promptWebauthn && c.cfg.AuthenticatorAttachment != wancli.AttachmentAuto { + // If we have multiple viable options, prefer Webauthn > SSO > OTP. + switch { + case promptWebauthn: + chosenMethods = []string{cliMFATypeWebauthn} + promptSSO = false + // Allow dual prompt with OTP. + if promptOTP { + chosenMethods = append(chosenMethods, cliMFATypeOTP) + } + case promptSSO: + chosenMethods = []string{cliMFATypeSSO} promptOTP = false + case promptOTP: + chosenMethods = []string{cliMFATypeOTP} + } + + // If there are multiple options and we chose one without it being specifically + // requested by the user, notify the user about it and how to request a specific method. + if len(availableMethods) > len(chosenMethods) && len(chosenMethods) > 0 && !userSpecifiedMethod { + const msg = "" + + "Available MFA methods [%v]. Continuing with %v.\n" + + "If you wish to perform MFA with another method, specify with flag --mfa-mode=.\n\n" + fmt.Fprintf(c.writer(), msg, strings.Join(availableMethods, ", "), strings.Join(chosenMethods, " and ")) } // DELETE IN v18.0 after TOTP session MFA support is removed (codingllama) @@ -157,12 +216,14 @@ func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng case promptWebauthn: resp, err := c.promptWebauthn(ctx, chal, c.getWebauthnPrompt(ctx)) return resp, trace.Wrap(err) + case promptSSO: + resp, err := c.promptSSO(ctx, chal) + return resp, trace.Wrap(err) case promptOTP: resp, err := c.promptOTP(ctx, c.cfg.Quiet) return resp, trace.Wrap(err) default: - // We shouldn't reach this case as we would have hit the no-op case above. - return nil, trace.BadParameter("no MFA methods to prompt") + return nil, trace.BadParameter("client does not support any available MFA methods [%v], see debug logs for details", strings.Join(availableMethods, ", ")) } } @@ -315,3 +376,8 @@ func (w *webauthnPromptWithOTP) PromptPIN() (string, error) { return w.LoginPrompt.PromptPIN() } + +func (c *CLIPrompt) promptSSO(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) { + resp, err := c.cfg.SSOMFACeremony.Run(ctx, chal) + return resp, trace.Wrap(err) +} diff --git a/lib/client/mfa/cli_test.go b/lib/client/mfa/cli_test.go index 54e0fcfd92fd9..b9b69b7c16f2d 100644 --- a/lib/client/mfa/cli_test.go +++ b/lib/client/mfa/cli_test.go @@ -43,6 +43,7 @@ func TestCLIPrompt(t *testing.T) { name string stdin string challenge *proto.MFAAuthenticateChallenge + modifyPromptConfig func(cfg *mfa.CLIPromptConfig) expectErr error expectStdOut string expectResp *proto.MFAAuthenticateResponse @@ -65,7 +66,7 @@ func TestCLIPrompt(t *testing.T) { }, }, }, { - name: "OK totp", + name: "OK otp", expectStdOut: "Enter an OTP code from a device:\n", stdin: "123456", challenge: &proto.MFAAuthenticateChallenge{ @@ -79,11 +80,83 @@ func TestCLIPrompt(t *testing.T) { }, }, }, { - name: "OK webauthn or totp choose webauthn", - expectStdOut: "Tap any security key or enter a code from a OTP device\n", + name: "OK sso", + expectStdOut: "", // sso stdout is handled internally in the SSO ceremony, which is mocked in this test. + challenge: &proto.MFAAuthenticateChallenge{ + SSOChallenge: &proto.SSOChallenge{}, + }, + expectResp: &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_SSO{ + SSO: &proto.SSOResponse{ + RequestId: "request-id", + Token: "mfa-token", + }, + }, + }, + }, { + name: "OK prefer otp when specified", + expectStdOut: "Enter an OTP code from a device:\n", + stdin: "123456", + challenge: &proto.MFAAuthenticateChallenge{ + WebauthnChallenge: &webauthnpb.CredentialAssertion{}, + TOTP: &proto.TOTPChallenge{}, + SSOChallenge: &proto.SSOChallenge{}, + }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.PreferOTP = true + }, + expectResp: &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_TOTP{ + TOTP: &proto.TOTPResponse{ + Code: "123456", + }, + }, + }, + }, { + name: "OK prefer sso when specified", + expectStdOut: "", challenge: &proto.MFAAuthenticateChallenge{ WebauthnChallenge: &webauthnpb.CredentialAssertion{}, TOTP: &proto.TOTPChallenge{}, + SSOChallenge: &proto.SSOChallenge{}, + }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.PreferSSO = true + }, + expectResp: &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_SSO{ + SSO: &proto.SSOResponse{ + RequestId: "request-id", + Token: "mfa-token", + }, + }, + }, + }, { + name: "OK prefer webauthn with authenticator attachment requested", + expectStdOut: "Tap any security key\n", + challenge: &proto.MFAAuthenticateChallenge{ + WebauthnChallenge: &webauthnpb.CredentialAssertion{}, + TOTP: &proto.TOTPChallenge{}, + SSOChallenge: &proto.SSOChallenge{}, + }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.AuthenticatorAttachment = wancli.AttachmentPlatform + }, + expectResp: &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_Webauthn{ + Webauthn: &webauthnpb.CredentialAssertionResponse{}, + }, + }, + }, + { + name: "OK prefer webauthn over sso", + expectStdOut: "" + + "Available MFA methods [WEBAUTHN, SSO]. Continuing with WEBAUTHN.\n" + + "If you wish to perform MFA with another method, specify with flag --mfa-mode=.\n\n" + + "Tap any security key\n", + challenge: &proto.MFAAuthenticateChallenge{ + WebauthnChallenge: &webauthnpb.CredentialAssertion{}, + SSOChallenge: &proto.SSOChallenge{}, }, expectResp: &proto.MFAAuthenticateResponse{ Response: &proto.MFAAuthenticateResponse_Webauthn{ @@ -91,12 +164,89 @@ func TestCLIPrompt(t *testing.T) { }, }, }, { - name: "OK webauthn or totp choose totp", - expectStdOut: "Tap any security key or enter a code from a OTP device\n", - stdin: "123456", + name: "OK prefer webauthn+otp over sso", + expectStdOut: "" + + "Available MFA methods [WEBAUTHN, SSO, OTP]. Continuing with WEBAUTHN and OTP.\n" + + "If you wish to perform MFA with another method, specify with flag --mfa-mode=.\n\n" + + "Tap any security key or enter a code from a OTP device\n", challenge: &proto.MFAAuthenticateChallenge{ WebauthnChallenge: &webauthnpb.CredentialAssertion{}, TOTP: &proto.TOTPChallenge{}, + SSOChallenge: &proto.SSOChallenge{}, + }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.AllowStdinHijack = true + }, + expectResp: &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_Webauthn{ + Webauthn: &webauthnpb.CredentialAssertionResponse{}, + }, + }, + }, { + name: "OK prefer sso over otp", + expectStdOut: "" + + "Available MFA methods [SSO, OTP]. Continuing with SSO.\n" + + "If you wish to perform MFA with another method, specify with flag --mfa-mode=.\n\n", + challenge: &proto.MFAAuthenticateChallenge{ + TOTP: &proto.TOTPChallenge{}, + SSOChallenge: &proto.SSOChallenge{}, + }, + expectResp: &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_SSO{ + SSO: &proto.SSOResponse{ + RequestId: "request-id", + Token: "mfa-token", + }, + }, + }, + }, { + name: "OK prefer webauthn over otp when stdin hijack disallowed", + expectStdOut: "" + + "Available MFA methods [WEBAUTHN, OTP]. Continuing with WEBAUTHN.\n" + + "If you wish to perform MFA with another method, specify with flag --mfa-mode=.\n\n" + + "Tap any security key\n", + challenge: &proto.MFAAuthenticateChallenge{ + WebauthnChallenge: &webauthnpb.CredentialAssertion{}, + TOTP: &proto.TOTPChallenge{}, + }, + expectResp: &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_Webauthn{ + Webauthn: &webauthnpb.CredentialAssertionResponse{}, + }, + }, + }, { + name: "OK webauthn or otp with stdin hijack allowed, choose webauthn", + expectStdOut: "" + + "Available MFA methods [WEBAUTHN, SSO, OTP]. Continuing with WEBAUTHN and OTP.\n" + + "If you wish to perform MFA with another method, specify with flag --mfa-mode=.\n\n" + + "Tap any security key or enter a code from a OTP device\n", + challenge: &proto.MFAAuthenticateChallenge{ + WebauthnChallenge: &webauthnpb.CredentialAssertion{}, + TOTP: &proto.TOTPChallenge{}, + SSOChallenge: &proto.SSOChallenge{}, + }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.AllowStdinHijack = true + }, + expectResp: &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_Webauthn{ + Webauthn: &webauthnpb.CredentialAssertionResponse{}, + }, + }, + }, { + name: "OK webauthn or otp with stdin hijack allowed, choose otp", + expectStdOut: "" + + "Available MFA methods [WEBAUTHN, SSO, OTP]. Continuing with WEBAUTHN and OTP.\n" + + "If you wish to perform MFA with another method, specify with flag --mfa-mode=.\n\n" + + "Tap any security key or enter a code from a OTP device\n", + stdin: "123456", + challenge: &proto.MFAAuthenticateChallenge{ + WebauthnChallenge: &webauthnpb.CredentialAssertion{}, + TOTP: &proto.TOTPChallenge{}, + SSOChallenge: &proto.SSOChallenge{}, + }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.AllowStdinHijack = true }, expectResp: &proto.MFAAuthenticateResponse{ Response: &proto.MFAAuthenticateResponse_TOTP{ @@ -113,19 +263,29 @@ func TestCLIPrompt(t *testing.T) { }, expectErr: context.DeadlineExceeded, }, { - name: "NOK no totp response", + name: "NOK no sso response", + expectStdOut: "", + challenge: &proto.MFAAuthenticateChallenge{ + SSOChallenge: &proto.SSOChallenge{}, + }, + expectErr: context.DeadlineExceeded, + }, { + name: "NOK no otp response", expectStdOut: "Enter an OTP code from a device:\n", challenge: &proto.MFAAuthenticateChallenge{ TOTP: &proto.TOTPChallenge{}, }, expectErr: context.DeadlineExceeded, }, { - name: "NOK no webauthn or totp response", + name: "NOK no webauthn or otp response", expectStdOut: "Tap any security key or enter a code from a OTP device\n", challenge: &proto.MFAAuthenticateChallenge{ WebauthnChallenge: &webauthnpb.CredentialAssertion{}, TOTP: &proto.TOTPChallenge{}, }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.AllowStdinHijack = true + }, expectErr: context.DeadlineExceeded, }, { @@ -134,6 +294,9 @@ func TestCLIPrompt(t *testing.T) { TOTP: &proto.TOTPChallenge{}, WebauthnChallenge: &webauthnpb.CredentialAssertion{}, }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.AllowStdinHijack = true + }, expectStdOut: `Tap any security key or enter a code from a OTP device Detected security key tap Enter your security key PIN: @@ -224,19 +387,27 @@ Enter your security key PIN: } }, }, + { + name: "NOK webauthn and SSO not supported", + challenge: &proto.MFAAuthenticateChallenge{ + SSOChallenge: &proto.SSOChallenge{}, + WebauthnChallenge: &webauthnpb.CredentialAssertion{}, + }, + modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) { + cfg.WebauthnSupported = false + cfg.SSOMFACeremony = nil + }, + expectErr: trace.BadParameter("client does not support any available MFA methods [WEBAUTHN, SSO], see debug logs for details"), + }, } { t.Run(tc.name, func(t *testing.T) { ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() - oldStdin := prompt.Stdin() - t.Cleanup(func() { prompt.SetStdin(oldStdin) }) - stdin := prompt.NewFakeReader() if tc.stdin != "" { stdin.AddString(tc.stdin) } - prompt.SetStdin(stdin) cfg := mfa.NewPromptConfig("proxy.example.com") cfg.WebauthnSupported = true @@ -257,16 +428,26 @@ Enter your security key PIN: } } + cfg.SSOMFACeremony = &mockSSOMFACeremony{ + mfaResp: tc.expectResp, + } + buffer := make([]byte, 0, 100) out := bytes.NewBuffer(buffer) - prompt := mfa.NewCLIPromptV2(&mfa.CLIPromptConfig{ - PromptConfig: *cfg, - Writer: out, - AllowStdinHijack: true, - }) - resp, err := prompt.Run(ctx, tc.challenge) + cliPromptConfig := &mfa.CLIPromptConfig{ + PromptConfig: *cfg, + Writer: out, + StdinFunc: func() prompt.StdinReader { + return stdin + }, + } + if tc.modifyPromptConfig != nil { + tc.modifyPromptConfig(cliPromptConfig) + } + + resp, err := mfa.NewCLIPromptV2(cliPromptConfig).Run(ctx, tc.challenge) if tc.expectErr != nil { require.ErrorIs(t, err, tc.expectErr) } else { @@ -278,3 +459,24 @@ Enter your security key PIN: }) } } + +type mockSSOMFACeremony struct { + mfaResp *proto.MFAAuthenticateResponse +} + +func (m *mockSSOMFACeremony) GetClientCallbackURL() string { + return "" +} + +// Run the SSO MFA ceremony. +func (m *mockSSOMFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) { + if m.mfaResp == nil { + return nil, context.DeadlineExceeded + } + if m.mfaResp.GetSSO() == nil { + return nil, trace.BadParameter("expected an SSO response but got %T", m.mfaResp.Response) + } + return m.mfaResp, nil +} + +func (m *mockSSOMFACeremony) Close() {} diff --git a/lib/client/sso/ceremony.go b/lib/client/sso/ceremony.go index cb5b57c5a3183..8a2a64debfe49 100644 --- a/lib/client/sso/ceremony.go +++ b/lib/client/sso/ceremony.go @@ -23,6 +23,7 @@ import ( "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/lib/auth/authclient" ) @@ -61,3 +62,66 @@ func NewCLICeremony(rd *Redirector, init CeremonyInit) *Ceremony { GetCallbackResponse: rd.WaitForResponse, } } + +// Ceremony is a customizable SSO MFA ceremony. +type MFACeremony struct { + clientCallbackURL string + close func() + HandleRedirect func(ctx context.Context, redirectURL string) error + GetCallbackMFAToken func(ctx context.Context) (string, error) +} + +// GetClientCallbackURL returns the client callback URL. +func (m *MFACeremony) GetClientCallbackURL() string { + return m.clientCallbackURL +} + +// Run the SSO MFA ceremony. +func (m *MFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) { + if err := m.HandleRedirect(ctx, chal.SSOChallenge.RedirectUrl); err != nil { + return nil, trace.Wrap(err) + } + + mfaToken, err := m.GetCallbackMFAToken(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + return &proto.MFAAuthenticateResponse{ + Response: &proto.MFAAuthenticateResponse_SSO{ + SSO: &proto.SSOResponse{ + RequestId: chal.SSOChallenge.RequestId, + Token: mfaToken, + }, + }, + }, nil +} + +// Close closes resources associated with the SSO MFA ceremony. +func (m *MFACeremony) Close() { + if m.close != nil { + m.close() + } +} + +// NewCLIMFACeremony creates a new CLI SSO ceremony from the given redirector. +// The returned MFACeremony takes ownership of the Redirector. +func NewCLIMFACeremony(rd *Redirector) *MFACeremony { + return &MFACeremony{ + clientCallbackURL: rd.ClientCallbackURL, + close: rd.Close, + HandleRedirect: rd.OpenRedirect, + GetCallbackMFAToken: func(ctx context.Context) (string, error) { + loginResp, err := rd.WaitForResponse(ctx) + if err != nil { + return "", trace.Wrap(err) + } + + if loginResp.MFAToken == "" { + return "", trace.BadParameter("login response for SSO MFA flow missing MFA token") + } + + return loginResp.MFAToken, nil + }, + } +} diff --git a/lib/client/sso/ceremony_test.go b/lib/client/sso/ceremony_test.go index 0851ac1b4daf2..4ea904697c8aa 100644 --- a/lib/client/sso/ceremony_test.go +++ b/lib/client/sso/ceremony_test.go @@ -26,22 +26,25 @@ import ( "net/http/httptest" "regexp" "testing" - "text/template" "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/lib/client/sso" "github.com/gravitational/teleport/lib/web" ) func TestCLICeremony(t *testing.T) { + ctx := context.Background() + mockProxy := newMockProxy(t) username := "alice" // Capture stderr. - stderr := bytes.NewBuffer([]byte{}) + stderr := &bytes.Buffer{} // Create a basic redirector. rd, err := sso.NewRedirector(sso.RedirectorConfig{ @@ -69,7 +72,66 @@ func TestCLICeremony(t *testing.T) { return mockIdPServer.URL, nil }) - template.New("Failed to open a browser window for login: %v\n") + // Modify handle redirect to also browse to the clickable URL printed to stderr. + baseHandleRedirect := ceremony.HandleRedirect + ceremony.HandleRedirect = func(ctx context.Context, redirectURL string) error { + if err := baseHandleRedirect(ctx, redirectURL); err != nil { + return trace.Wrap(err) + } + + // Read the clickable url from stderr and navigate to it + // using a simplified regexp for http://127.0.0.1:/ + const clickableURLPattern = `http://127.0.0.1:\d+/[0-9A-Fa-f-]+` + clickableURL := regexp.MustCompile(clickableURLPattern).FindString(stderr.String()) + resp, err := http.Get(clickableURL) + require.NoError(t, err) + defer resp.Body.Close() + + // User should be redirected to success screen. + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, sso.LoginSuccessRedirectURL, string(body)) + return nil + } + + loginResp, err := ceremony.Run(ctx) + require.NoError(t, err) + require.Equal(t, username, loginResp.Username) +} + +func TestCLICeremony_MFA(t *testing.T) { + const token = "sso-mfa-token" + const requestID = "soo-mfa-request-id" + + ctx := context.Background() + mockProxy := newMockProxy(t) + + // Capture stderr. + stderr := bytes.NewBuffer([]byte{}) + + // Create a basic redirector. + rd, err := sso.NewRedirector(sso.RedirectorConfig{ + ProxyAddr: mockProxy.URL, + Browser: teleport.BrowserNone, + Stderr: stderr, + }) + require.NoError(t, err) + + // Construct a fake mfa response with the redirector's client callback URL. + successResponseURL, err := web.ConstructSSHResponse(web.AuthParams{ + ClientRedirectURL: rd.ClientCallbackURL, + MFAToken: token, + }) + require.NoError(t, err) + + // Open a mock IdP server which will handle a redirect and result in the expected IdP session payload. + mockIdPServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, successResponseURL.String(), http.StatusPermanentRedirect) + })) + t.Cleanup(mockIdPServer.Close) + + ceremony := sso.NewCLIMFACeremony(rd) + t.Cleanup(ceremony.Close) // Modify handle redirect to also browse to the clickable URL printed to stderr. baseHandleRedirect := ceremony.HandleRedirect @@ -94,7 +156,14 @@ func TestCLICeremony(t *testing.T) { return nil } - loginResp, err := ceremony.Run(context.Background()) + mfaResponse, err := ceremony.Run(ctx, &proto.MFAAuthenticateChallenge{ + SSOChallenge: &proto.SSOChallenge{ + RedirectUrl: mockIdPServer.URL, + RequestId: requestID, + }, + }) require.NoError(t, err) - require.Equal(t, username, loginResp.Username) + require.NotNil(t, mfaResponse.GetSSO()) + assert.Equal(t, token, mfaResponse.GetSSO().Token) + assert.Equal(t, requestID, mfaResponse.GetSSO().RequestId) } diff --git a/tool/tctl/common/tctl.go b/tool/tctl/common/tctl.go index 48ad1f0b75b6d..448a459df1653 100644 --- a/tool/tctl/common/tctl.go +++ b/tool/tctl/common/tctl.go @@ -47,6 +47,7 @@ import ( "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/identityfile" libmfa "github.com/gravitational/teleport/lib/client/mfa" + "github.com/gravitational/teleport/lib/client/sso" "github.com/gravitational/teleport/lib/config" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/modules" @@ -257,6 +258,18 @@ func TryRun(commands []CLICommand, args []string) error { PromptConfig: *promptCfg, }) }) + client.SetSSOMFACeremonyConstructor(func(ctx context.Context) (mfa.SSOMFACeremony, error) { + rdConfig := sso.RedirectorConfig{ + ProxyAddr: proxyAddr, + } + + rd, err := sso.NewRedirector(rdConfig) + if err != nil { + return nil, trace.Wrap(err) + } + + return sso.NewCLIMFACeremony(rd), nil + }) // execute whatever is selected: var match bool diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 2164dea15d3ea..1adabe7b337c1 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -118,6 +118,8 @@ const ( mfaModePlatform = "platform" // mfaModeOTP utilizes only OTP devices. mfaModeOTP = "otp" + // mfaModeSSO utilizes only SSO devices. + mfaModeSSO = "sso" ) const ( @@ -766,7 +768,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { app.Flag("bind-addr", "Override host:port used when opening a browser for cluster logins").Envar(bindAddrEnvVar).StringVar(&cf.BindAddr) app.Flag("callback", "Override the base URL (host:port) of the link shown when opening a browser for cluster logins. Must be used with --bind-addr.").StringVar(&cf.CallbackAddr) app.Flag("browser-login", browserHelp).Hidden().Envar(browserEnvVar).StringVar(&cf.Browser) - modes := []string{mfaModeAuto, mfaModeCrossPlatform, mfaModePlatform, mfaModeOTP} + modes := []string{mfaModeAuto, mfaModeCrossPlatform, mfaModePlatform, mfaModeOTP, mfaModeSSO} app.Flag("mfa-mode", fmt.Sprintf("Preferred mode for MFA and Passwordless assertions (%v)", strings.Join(modes, ", "))). Default(mfaModeAuto). Envar(mfaModeEnvVar). @@ -4253,6 +4255,7 @@ func loadClientConfigFromCLIConf(cf *CLIConf, proxy string) (*client.Config, err } c.AuthenticatorAttachment = mfaOpts.AuthenticatorAttachment c.PreferOTP = mfaOpts.PreferOTP + c.PreferSSO = mfaOpts.PreferSSO // If agent forwarding was specified on the command line enable it. c.ForwardAgent = options.ForwardAgent @@ -4434,6 +4437,7 @@ func (c *CLIConf) GetProfile() (*profile.Profile, error) { type mfaModeOpts struct { AuthenticatorAttachment wancli.AuthenticatorAttachment PreferOTP bool + PreferSSO bool } func parseMFAMode(mode string) (*mfaModeOpts, error) { @@ -4446,6 +4450,8 @@ func parseMFAMode(mode string) (*mfaModeOpts, error) { opts.AuthenticatorAttachment = wancli.AttachmentPlatform case mfaModeOTP: opts.PreferOTP = true + case mfaModeSSO: + opts.PreferSSO = true default: return nil, fmt.Errorf("invalid MFA mode: %q", mode) } diff --git a/web/packages/teleport/src/Navigation/RecentHistory.tsx b/web/packages/teleport/src/Navigation/RecentHistory.tsx new file mode 100644 index 0000000000000..38ae478bd6b2a --- /dev/null +++ b/web/packages/teleport/src/Navigation/RecentHistory.tsx @@ -0,0 +1,300 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import React, { useState, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { matchPath } from 'react-router'; +import { NavLink } from 'react-router-dom'; +import { Flex, Text, H4, P3, ButtonIcon } from 'design'; +import { Cross } from 'design/Icon'; + +import { TeleportFeature } from 'teleport/types'; +import { useFeatures } from 'teleport/FeaturesContext'; + +import { getSubsectionStyles } from './SideNavigation/Section'; +import { SidenavCategory } from './SideNavigation/categories'; + +export type RecentHistoryItem = { + category?: SidenavCategory; + title: string; + route: string; + exact?: boolean; +}; + +type AnimatedItem = RecentHistoryItem & { + animationState: 'exiting' | 'entering' | ''; +}; + +function getIconForRoute( + features: TeleportFeature[], + route: string +): (props) => JSX.Element { + const feature = features.find(feature => + matchPath(route, { + path: feature?.route?.path, + exact: false, + }) + ); + + const icon = feature?.navigationItem?.icon || feature?.topMenuItem?.icon; + if (!icon) { + return () => null; + } + + return icon; +} + +export function RecentHistory({ + recentHistoryItems, + onRemoveItem, +}: { + recentHistoryItems: RecentHistoryItem[]; + onRemoveItem: (route: string) => void; +}) { + const features = useFeatures(); + const [animatedItems, setAnimatedItems] = useState([]); + const prevItemsRef = useRef([]); + + useEffect(() => { + const prevItems = prevItemsRef.current; + let newAnimatedItems = recentHistoryItems.map(item => ({ + ...item, + animationState: '', + })) as AnimatedItem[]; + + const isFirstItemDeleted = + recentHistoryItems.findIndex( + item => item.route === prevItems[0]?.route + ) === -1; + + // If an item the previous list is not in the new list (deleted) OR was moved, animate it out. + prevItems.forEach((prevItem, index) => { + if ( + !recentHistoryItems.some(item => item.route === prevItem.route) || + (prevItem?.route !== recentHistoryItems[index]?.route && + recentHistoryItems[0]?.route === prevItem?.route) + ) { + // If the item is now in the first position (meaning it was moved to the top), animate it in at the top in addition to animating it out in its previous position. + if ( + recentHistoryItems.length > 0 && + prevItems[0]?.route !== recentHistoryItems[0]?.route && + !isFirstItemDeleted + ) { + newAnimatedItems.splice(0, 1); + newAnimatedItems = [ + { ...prevItem, animationState: 'entering' }, + ...newAnimatedItems.slice(0, index), + { ...prevItem, animationState: 'exiting' }, + ...newAnimatedItems.slice(index), + ]; + } else if ( + !recentHistoryItems.some(item => item.route === prevItem.route) + ) { + newAnimatedItems = [ + ...newAnimatedItems.slice(0, index), + { ...prevItem, animationState: 'exiting' }, + ...newAnimatedItems.slice(index), + ]; + } + } + }); + + setAnimatedItems(newAnimatedItems); + prevItemsRef.current = recentHistoryItems; + + // Clean up animated items after animation + const deletedItemTimeout = setTimeout(() => { + setAnimatedItems(items => + items.filter(item => item.animationState !== 'exiting') + ); + }, 300); + const newItemsTimeout = setTimeout(() => { + setAnimatedItems(items => + items.map(item => ({ ...item, animationState: '' })) + ); + }, 400); + + return () => { + clearTimeout(deletedItemTimeout); + clearTimeout(newItemsTimeout); + }; + }, [recentHistoryItems]); + + return ( + +

+ Recent Pages +

+ {!!animatedItems.length && ( + + {animatedItems.map((item, index) => { + const Icon = getIconForRoute(features, item.route); + return ( + onRemoveItem(item.route)} + /> + ); + })} + + )} +
+ ); +} + +function AnimatedHistoryItem({ + item, + Icon, + onRemove, +}: { + item: AnimatedItem; + Icon: (props) => JSX.Element; + onRemove: () => void; +}) { + const [hovered, setHovered] = useState(false); + const itemRef = useRef(null); + + useEffect(() => { + if (item.animationState === 'exiting' && itemRef.current) { + const height = item.category ? 60 : 40; + itemRef.current.style.height = `${height}px`; + itemRef.current.style.opacity = '1'; + itemRef.current.offsetHeight; // Force reflow + requestAnimationFrame(() => { + if (itemRef.current) { + itemRef.current.style.height = '0px'; + itemRef.current.style.opacity = '0'; + } + }); + } + + if (item.animationState === 'entering' && itemRef.current) { + const height = item.category ? 60 : 40; + itemRef.current.style.height = `0px`; + itemRef.current.style.opacity = '0'; + itemRef.current.offsetHeight; // Force reflow + requestAnimationFrame(() => { + if (itemRef.current) { + itemRef.current.style.height = `${height}px`; + itemRef.current.style.opacity = '1'; + } + }); + } + }, [item.animationState]); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + onMouseOver={() => setHovered(true)} + style={{ height: item.animationState === 'entering' ? 0 : 'auto' }} + > + + + + + + + + {item.title} + + {item.category && {item.category}} + + + {hovered && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={e => { + e.stopPropagation(); + e.preventDefault(); + onRemove(); + }} + > + + + )} + + + ); +} + +const AnimatedItemWrapper = styled.div<{ + isExiting: boolean; + isEntering: boolean; +}>` + overflow: hidden; + height: auto; + width: 100%; + transition: all 0.3s ease-in-out; + padding: 3px; + + ${props => + props.isEntering && + ` + transition: all 0.3s ease-in-out 0.1s; + pointer-events: none; + opacity: 0; + `} + + ${props => + props.isExiting && + ` + pointer-events: none; + `} +`; + +const StyledNavLink = styled(NavLink)` + padding: ${props => props.theme.space[2]}px ${props => props.theme.space[3]}px; + text-decoration: none; + user-select: none; + border-radius: ${props => props.theme.radii[2]}px; + max-width: 100%; + display: flex; + position: relative; + + cursor: pointer; + + ${props => getSubsectionStyles(props.theme, false)} +`; + +const DeleteButtonAlt = styled(ButtonIcon)<{ size: number }>` + position: absolute; + right: 0; + top: 0; + height: ${props => props.size}px; + width: ${props => props.size}px; + border-radius: ${props => props.theme.radii[2]}px; +`; diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx index 2099a09abd61f..9ba01936767d4 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx +++ b/web/packages/teleport/src/Navigation/SideNavigation/Navigation.tsx @@ -16,7 +16,13 @@ * along with this program. If not, see . */ -import React, { useState, useCallback, useEffect, useRef } from 'react'; +import React, { + useState, + useCallback, + useEffect, + useRef, + useMemo, +} from 'react'; import styled, { useTheme } from 'styled-components'; import { matchPath, useHistory } from 'react-router'; import { Text, Flex, Box, P2 } from 'design'; @@ -72,15 +78,18 @@ const PanelBackground = styled.div` /* NavigationSection is a section in the navbar, this can either be a standalone section (clickable button with no drawer), or a category with subsections shown in a drawer that expands. */ export type NavigationSection = { - category: SidenavCategory; + category?: SidenavCategory; subsections?: NavigationSubsection[]; /* standalone is whether this is a clickable nav section with no subsections/drawer. */ standalone?: boolean; }; -/* NavigationSubsection is a subsection of a NavigationSection, these are the items listed in the drawer of a NavigationSection. */ +/** + * NavigationSubsection is a subsection of a NavigationSection, these are the items listed in the drawer of a NavigationSection, or if isTopMenuItem is true, in the top menu (eg. Account Settings). + */ export type NavigationSubsection = { - category: SidenavCategory; + category?: SidenavCategory; + isTopMenuItem?: boolean; title: string; route: string; exact: boolean; @@ -139,6 +148,26 @@ function getSubsectionsForCategory( // getNavSubsectionForRoute returns the sidenav subsection that the user is correctly on (based on route). // Note that it is possible for this not to return anything, such as in the case where the user is on a page that isn't in the sidenav (eg. Account Settings). +/** + * getTopMenuSection returns a NavigationSection with the top menu items. This is not used in the sidenav, but will be used to make the top menu items searchable. + */ +function getTopMenuSection(features: TeleportFeature[]): NavigationSection { + const topMenuItems = features.filter( + feature => !!feature.topMenuItem && !feature.sideNavCategory + ); + + return { + subsections: topMenuItems.map(feature => ({ + isTopMenuItem: true, + title: feature.topMenuItem.title, + route: feature.topMenuItem.getLink(cfg.proxyCluster), + exact: feature?.route?.exact, + icon: feature.topMenuItem.icon, + searchableTags: feature.topMenuItem.searchableTags, + })), + }; +} + function getNavSubsectionForRoute( features: TeleportFeature[], route: history.Location | Location @@ -152,10 +181,22 @@ function getNavSubsectionForRoute( }) ); - if (!feature || !feature.sideNavCategory) { + if (!feature || (!feature.sideNavCategory && !feature.topMenuItem)) { return; } + if (feature.topMenuItem) { + return { + isTopMenuItem: true, + exact: feature.route.exact, + title: feature.topMenuItem.title, + route: feature.topMenuItem.getLink(cfg.proxyCluster), + icon: feature.topMenuItem.icon, + searchableTags: feature.topMenuItem.searchableTags, + category: feature?.sideNavCategory, + }; + } + return { category: feature.sideNavCategory, title: feature.navigationItem.title, @@ -223,11 +264,15 @@ export function Navigation() { } }; }, []); - const currentView = getNavSubsectionForRoute(features, history.location); + const currentView = useMemo( + () => getNavSubsectionForRoute(features, history.location), + [history.location] + ); const navSections = getNavigationSections(features).filter( section => section.subsections.length ); + const topMenuSection = getTopMenuSection(features); const handleSetExpandedSection = useCallback( (section: NavigationSection) => { @@ -300,7 +345,7 @@ export function Navigation() { . */ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { NavLink } from 'react-router-dom'; import styled from 'styled-components'; -import { Box, Flex, Text } from 'design'; +import { Box, Flex, P3, Text } from 'design'; import { height, space, color } from 'design/system'; import useStickyClusterId from 'teleport/useStickyClusterId'; import { useUser } from 'teleport/User/UserContext'; +import { storageService } from 'teleport/services/storageService'; + +import { RecentHistory, RecentHistoryItem } from '../RecentHistory'; import { NavigationSection, NavigationSubsection } from './Navigation'; import { @@ -33,7 +36,6 @@ import { verticalPadding, getSubsectionStyles, } from './Section'; -import { CategoryIcon } from './CategoryIcon'; import { CustomNavigationCategory } from './categories'; import { getResourcesSectionForSearch } from './ResourcesSection'; @@ -120,6 +122,28 @@ function SearchContent({ ) ); + const [recentHistory, setRecentHistory] = useState( + storageService.getRecentHistory() + ); + + useEffect(() => { + if (currentView) { + const newRecentHistory = storageService.addRecentHistoryItem({ + category: currentView?.category, + title: currentView?.title, + route: currentView?.route, + exact: currentView?.exact, + }); + + setRecentHistory(newRecentHistory); + } + }, [currentView]); + + function handleRemoveItem(route: string) { + const newRecentHistory = storageService.removeRecentHistoryItem(route); + setRecentHistory(newRecentHistory); + } + return ( @@ -150,6 +174,12 @@ function SearchContent({ ))} )} + {searchInput.length === 0 && ( + + )} ); @@ -170,20 +200,16 @@ function SearchResult({ onClick={subsection.onClick} > - - - + + + {subsection.title} - - {subsection.category} - + {subsection.category && ( + {subsection.category} + )} diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index f1aab44471ad5..722d97fb565e3 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -645,6 +645,13 @@ export class FeatureAccount implements TeleportFeature { getLink() { return cfg.routes.account; }, + searchableTags: [ + 'account settings', + 'settings', + 'password', + 'mfa', + 'change password', + ], }; } @@ -667,6 +674,7 @@ export class FeatureHelpAndSupport implements TeleportFeature { getLink() { return cfg.routes.support; }, + searchableTags: ['help', 'support', NavTitle.HelpAndSupport], }; } diff --git a/web/packages/teleport/src/services/storageService/storageService.ts b/web/packages/teleport/src/services/storageService/storageService.ts index 92b9b3ec3e283..ebef656ff7b58 100644 --- a/web/packages/teleport/src/services/storageService/storageService.ts +++ b/web/packages/teleport/src/services/storageService/storageService.ts @@ -28,6 +28,7 @@ import { convertBackendUserPreferences, isBackendUserPreferences, } from 'teleport/services/userPreferences/userPreferences'; +import { RecentHistoryItem } from 'teleport/Navigation/RecentHistory'; import { CloudUserInvites, KeysEnum, LocalStorageSurvey } from './types'; @@ -41,8 +42,11 @@ const KEEP_LOCALSTORAGE_KEYS_ON_LOGOUT = [ KeysEnum.LICENSE_ACKNOWLEDGED, KeysEnum.USERS_NOT_EQUAL_TO_MAU_ACKNOWLEDGED, KeysEnum.USE_NEW_ROLE_EDITOR, + KeysEnum.RECENT_HISTORY, ]; +const RECENT_HISTORY_MAX_LENGTH = 10; + export const storageService = { clear() { Object.keys(window.localStorage).forEach(key => { @@ -265,4 +269,45 @@ export const storageService = { getIsTopBarView(): boolean { return this.getParsedJSONValue(KeysEnum.USE_TOP_BAR, false); }, + + getRecentHistory(): RecentHistoryItem[] { + return this.getParsedJSONValue(KeysEnum.RECENT_HISTORY, []); + }, + + addRecentHistoryItem(item: RecentHistoryItem): RecentHistoryItem[] { + const history = storageService.getRecentHistory(); + const deduplicatedHistory = [...history]; + + // Remove a duplicate item if it exists. + const existingDuplicateIndex = history.findIndex( + historyItem => historyItem.route === item.route + ); + if (existingDuplicateIndex !== -1) { + deduplicatedHistory.splice(existingDuplicateIndex, 1); + } + + const newHistory = [item, ...deduplicatedHistory].slice( + 0, + RECENT_HISTORY_MAX_LENGTH + ); + + window.localStorage.setItem( + KeysEnum.RECENT_HISTORY, + JSON.stringify(newHistory) + ); + + return newHistory; + }, + + removeRecentHistoryItem(route: string): RecentHistoryItem[] { + const history = storageService.getRecentHistory(); + const newHistory = history.filter(item => item.route !== route); + + window.localStorage.setItem( + KeysEnum.RECENT_HISTORY, + JSON.stringify(newHistory) + ); + + return newHistory; + }, }; diff --git a/web/packages/teleport/src/services/storageService/types.ts b/web/packages/teleport/src/services/storageService/types.ts index f689442d13368..fa04cc88c704d 100644 --- a/web/packages/teleport/src/services/storageService/types.ts +++ b/web/packages/teleport/src/services/storageService/types.ts @@ -36,6 +36,7 @@ export const KeysEnum = { USERS_NOT_EQUAL_TO_MAU_ACKNOWLEDGED: 'grv_users_not_equal_to_mau_acknowledged', LOCAL_NOTIFICATION_STATES: 'grv_teleport_notification_states', + RECENT_HISTORY: 'grv_teleport_sidenav_recent_history', // TODO(bl-nero): Remove once the new role editor is in acceptable state. USE_NEW_ROLE_EDITOR: 'grv_teleport_use_new_role_editor',