diff --git a/api/mfa/prompt.go b/api/mfa/prompt.go index 95eb568820e..45f902f2d73 100644 --- a/api/mfa/prompt.go +++ b/api/mfa/prompt.go @@ -88,9 +88,14 @@ func WithPromptReasonAdminAction() PromptOpt { } // WithPromptReasonSessionMFA sets the prompt's PromptReason field to a standard session mfa message. -func WithPromptReasonSessionMFA(serviceType, serviceName string) PromptOpt { +// If leafClusterName is non-empty, it is included in the message to indicate that the resource +// belongs to a leaf cluster. +func WithPromptReasonSessionMFA(serviceType, serviceName, leafClusterName string) PromptOpt { return func(cfg *PromptConfig) { cfg.PromptReason = fmt.Sprintf("MFA is required to access %s %q", serviceType, serviceName) + if leafClusterName != "" { + cfg.PromptReason += fmt.Sprintf(" from leaf cluster %q", leafClusterName) + } // Set the extensions to scope USER_SESSION, which we know is true, but // don't override any explicitly-set extensions (as they are likely more diff --git a/lib/client/cluster_client.go b/lib/client/cluster_client.go index 469a39676c0..287d8445af1 100644 --- a/lib/client/cluster_client.go +++ b/lib/client/cluster_client.go @@ -495,25 +495,31 @@ func (c *ClusterClient) performSessionMFACeremony(ctx context.Context, rootClien return nil, trace.Wrap(err) } + mfaAgainstRoot := c.cluster == rootClient.cluster + var leafClusterName string + if !mfaAgainstRoot { + leafClusterName = c.cluster + } + var promptOpts []mfa.PromptOpt switch { case params.NodeName != "": - promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("Node", params.NodeName)) + promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("node", params.NodeName, leafClusterName)) case params.KubernetesCluster != "": - promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("Kubernetes cluster", params.KubernetesCluster)) + promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("Kubernetes cluster", params.KubernetesCluster, leafClusterName)) case params.RouteToDatabase.ServiceName != "": - promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("Database", params.RouteToDatabase.ServiceName)) + promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("database", params.RouteToDatabase.ServiceName, leafClusterName)) case params.RouteToApp.Name != "": - promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("Application", params.RouteToApp.Name)) + promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("application", params.RouteToApp.Name, leafClusterName)) case params.RouteToWindowsDesktop.WindowsDesktop != "": - promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("Windows desktop", params.RouteToWindowsDesktop.WindowsDesktop)) + promptOpts = append(promptOpts, mfa.WithPromptReasonSessionMFA("Windows desktop", params.RouteToWindowsDesktop.WindowsDesktop, leafClusterName)) } result, err := PerformSessionMFACeremony(ctx, PerformSessionMFACeremonyParams{ CurrentAuthClient: c.AuthClient, RootAuthClient: rootClient.AuthClient, MFACeremony: c.tc.NewMFACeremony(), - MFAAgainstRoot: c.cluster == rootClient.cluster, + MFAAgainstRoot: mfaAgainstRoot, MFARequiredReq: mfaRequiredReq, CertsReq: certsReq, KeyRing: keyRing, diff --git a/lib/client/cluster_client_test.go b/lib/client/cluster_client_test.go index 8055b10a1a1..b9cb0d83485 100644 --- a/lib/client/cluster_client_test.go +++ b/lib/client/cluster_client_test.go @@ -17,6 +17,7 @@ package client import ( + "cmp" "context" "crypto/ecdsa" "crypto/rsa" @@ -174,7 +175,12 @@ func TestIssueUserCertsWithMFA(t *testing.T) { params ReissueParams prompt fakePrompt signatureAlgorithmSuite types.SignatureAlgorithmSuite - assertion func(t *testing.T, result *IssueUserCertsWithMFAResult, err error) + // clientCluster overrides the ClusterClient's cluster field. Defaults to "test". + clientCluster string + // wantPromptReason, when non-empty, asserts the PromptReason passed to the + // MFA prompt constructor. + wantPromptReason string + assertion func(t *testing.T, result *IssueUserCertsWithMFAResult, err error) }{ { name: "ssh no mfa", @@ -190,9 +196,10 @@ func TestIssueUserCertsWithMFA(t *testing.T) { }, }, { - name: "ssh mfa success", - mfaRequired: proto.MFARequired_MFA_REQUIRED_YES, - params: ReissueParams{NodeName: "test"}, + name: "ssh mfa success", + mfaRequired: proto.MFARequired_MFA_REQUIRED_YES, + params: ReissueParams{NodeName: "test"}, + wantPromptReason: `MFA is required to access node "test"`, assertion: func(t *testing.T, result *IssueUserCertsWithMFAResult, err error) { require.NoError(t, err) require.NotNil(t, result) @@ -546,6 +553,28 @@ func TestIssueUserCertsWithMFA(t *testing.T) { require.NotNil(t, result.KeyRing) }, }, + { + name: "session MFA from a leaf cluster mentions the leaf in the prompt reason", + mfaRequired: proto.MFARequired_MFA_REQUIRED_YES, + clientCluster: "leaf", + params: ReissueParams{ + NodeName: "test", + // In real world RouteToCluster would be "leaf", but this doesn't work + // with how the test is currently set up. + RouteToCluster: "test", + AuthClient: fakeAuthClient{ + isMFARequired: func(ctx context.Context, req *proto.IsMFARequiredRequest) (*proto.IsMFARequiredResponse, error) { + return &proto.IsMFARequiredResponse{MFARequired: proto.MFARequired_MFA_REQUIRED_YES, Required: true}, nil + }, + generateUserCerts: defaultGenerateUserCerts, + }, + }, + wantPromptReason: `MFA is required to access node "test" from leaf cluster "leaf"`, + assertion: func(t *testing.T, result *IssueUserCertsWithMFAResult, err error) { + require.NoError(t, err) + require.NotNil(t, result) + }, + }, } for _, test := range tests { @@ -563,6 +592,8 @@ func TestIssueUserCertsWithMFA(t *testing.T) { suite = types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1 } + var capturedPromptCfg *libmfa.PromptConfig + clientCluster := cmp.Or(test.clientCluster, "test") clt := &ClusterClient{ tc: &TeleportClient{ localAgent: agent, @@ -572,6 +603,7 @@ func TestIssueUserCertsWithMFA(t *testing.T) { HostLogin: "default-login", Tracer: tracing.NoopTracer("test"), MFAPromptConstructor: func(cfg *libmfa.PromptConfig) mfa.Prompt { + capturedPromptCfg = cfg return test.prompt }, Stderr: io.Discard, @@ -597,7 +629,7 @@ func TestIssueUserCertsWithMFA(t *testing.T) { generateUserCerts: defaultGenerateUserCerts, }, Tracer: tracing.NoopTracer("test"), - cluster: "test", + cluster: clientCluster, root: "test", } @@ -605,6 +637,11 @@ func TestIssueUserCertsWithMFA(t *testing.T) { result, err := clt.IssueUserCertsWithMFA(ctx, test.params) test.assertion(t, result, err) + + if test.wantPromptReason != "" { + require.NotNil(t, capturedPromptCfg, "MFA prompt constructor was not invoked") + require.Equal(t, test.wantPromptReason, capturedPromptCfg.PromptReason) + } }) } } diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptSsoStatus/PromptSsoStatus.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptSsoStatus/PromptSsoStatus.tsx index c44608120f0..fb73d2cfbd2 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptSsoStatus/PromptSsoStatus.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptSsoStatus/PromptSsoStatus.tsx @@ -28,8 +28,8 @@ export default function PromptSsoStatus(props: { }) { const { onCancel, ssoPrompt } = props; return ( - - + + {ssoPrompt === 'follow-browser-steps' && ( <>Please follow the steps in the browser to authenticate. diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.tsx index 9b39a536bf0..20b57c14d9c 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.tsx @@ -79,8 +79,6 @@ export const ReAuthenticate: FC<{ // In practice though we should not end up in a situation where this modal is shown but the // cluster does not exist in the app. rootCluster?.proxyHost || rootClusterName; - const clusterName = routing.parseClusterName(clusterUri); - const isLeafCluster = routing.isLeafCluster(clusterUri); // maybeOpenBrowser opens the browser if the given mfaType is SSO of Browser MFA function maybeOpenBrowser(mfaType: AvailableMfaType) { @@ -190,10 +188,7 @@ export const ReAuthenticate: FC<{ - - {req.reason} - {isLeafCluster && ` from trusted cluster "${clusterName}"`} - + {req.reason && {req.reason}} {availableMfaTypes.length > 1 && ( @@ -223,7 +218,7 @@ export const ReAuthenticate: FC<{ textAlign="center" style={{ position: 'relative' }} > - Insert your security key and tap it + Insert your security key and tap it. @@ -234,7 +229,11 @@ export const ReAuthenticate: FC<{ )} {selectedMfaType.value === 'browsermfa' && ( - + Please follow the steps in the browser to authenticate.