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/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) }