[Browser MFA] Add initial requests for browser MFA process to client tools (#64301)

This commit is contained in:
Dan Share
2026-03-26 20:29:19 +00:00
committed by GitHub
parent f39e0ef7d9
commit e0b8f614af
28 changed files with 708 additions and 222 deletions
+5 -5
View File
@@ -671,9 +671,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.
// MFACeremonyConstructor is used to handle SSO or Browser MFA when needed.
// If nil, the client will not prompt for MFA.
SSOMFACeremonyConstructor mfa.SSOMFACeremonyConstructor
MFACeremonyConstructor mfa.MFACeremonyConstructor
}
// CheckAndSetDefaults checks and sets default config values.
@@ -740,9 +740,9 @@ 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
// SetMFACeremonyConstructor sets the MFA ceremony constructor for this client.
func (c *Client) SetMFACeremonyConstructor(mcc mfa.MFACeremonyConstructor) {
c.c.MFACeremonyConstructor = mcc
}
// Close closes the Client connection to the auth server.
+1 -1
View File
@@ -30,7 +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,
MFACeremonyConstructor: c.c.MFACeremonyConstructor,
}
return mfaCeremony.Run(ctx, challengeRequest, promptOpts...)
}
+22 -18
View File
@@ -33,21 +33,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
// MFACeremonyConstructor is an optional MFA ceremony constructor. If provided,
// the MFA ceremony will also attempt to retrieve an MFA challenge.
MFACeremonyConstructor MFACeremonyConstructor
}
// SSOMFACeremony is an SSO MFA ceremony.
type SSOMFACeremony interface {
// CallbackCeremony is an SSO/Browser callback ceremony.
type CallbackCeremony interface {
GetClientCallbackURL() string
GetProxyAddress() 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)
// MFACeremonyConstructor constructs a new SSO or Browser MFA ceremony.
type MFACeremonyConstructor func(ctx context.Context) (CallbackCeremony, error)
// CreateAuthenticateChallengeFunc is a function that creates an authentication challenge.
type CreateAuthenticateChallengeFunc func(ctx context.Context, req *proto.CreateAuthenticateChallengeRequest) (*proto.MFAAuthenticateChallenge, error)
@@ -61,16 +61,16 @@ func (c *Ceremony) Run(ctx context.Context, req *proto.CreateAuthenticateChallen
return nil, trace.BadParameter("mfa ceremony must have CreateAuthenticateChallenge set in order to begin")
}
// 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 available, prepare an MFA ceremony and set the client redirect URL in the challenge
// request to request an SSO or Browser MFA challenge in addition to other challenges.
if c.MFACeremonyConstructor != nil {
mfaCeremony, err := c.MFACeremonyConstructor(ctx)
if err != nil {
// We may fail to start the SSO MFA flow in cases where the Proxy is down or broken. Fall
// back to skipping SSO MFA, especially since SSO MFA may not even be allowed on the server.
slog.DebugContext(ctx, "Failed to attempt SSO MFA, continuing with other MFA methods", "error", err)
// We may fail to start the MFA flow in cases where the Proxy is down or broken. Fall
// back to skipping SSO/Browser MFA, especially since SSO/Browser MFA may not even be allowed on the server.
slog.DebugContext(ctx, "Failed to attempt SSO/Browser MFA, continuing with other MFA methods", "error", err)
} else {
defer ssoMFACeremony.Close()
defer mfaCeremony.Close()
// req may be nil in cases where the ceremony's CreateAuthenticateChallenge sources
// its own req or uses a different e.g. login. We should still provide the sso client
@@ -79,9 +79,13 @@ func (c *Ceremony) Run(ctx context.Context, req *proto.CreateAuthenticateChallen
req = new(proto.CreateAuthenticateChallengeRequest)
}
req.SSOClientRedirectURL = ssoMFACeremony.GetClientCallbackURL()
req.ProxyAddress = ssoMFACeremony.GetProxyAddress()
promptOpts = append(promptOpts, withSSOMFACeremony(ssoMFACeremony))
req.SSOClientRedirectURL = mfaCeremony.GetClientCallbackURL()
// Reuse the same callback server for Browser MFA because only one of
// SSO MFA or Browser MFA can be used. Sending both redirect URLs
// indicates to the server that both methods are available.
req.BrowserMFATSHRedirectURL = mfaCeremony.GetClientCallbackURL()
req.ProxyAddress = mfaCeremony.GetProxyAddress()
promptOpts = append(promptOpts, withSSOMFACeremony(mfaCeremony))
}
}
+68 -10
View File
@@ -160,15 +160,15 @@ func TestMFACeremony_SSO(t *testing.T) {
}
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")
if cfg.MFACeremony == nil {
return nil, trace.BadParameter("expected mfa ceremony")
}
return cfg.SSOMFACeremony.Run(ctx, chal)
return cfg.MFACeremony.Run(ctx, chal)
})
},
SSOMFACeremonyConstructor: func(ctx context.Context) (mfa.SSOMFACeremony, error) {
return &mockSSOMFACeremony{
MFACeremonyConstructor: func(ctx context.Context) (mfa.CallbackCeremony, error) {
return &mockMFACeremony{
clientCallbackURL: "client-redirect",
prompt: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
return testMFAResponse, nil
@@ -187,23 +187,81 @@ func TestMFACeremony_SSO(t *testing.T) {
require.Equal(t, testMFAResponse, resp)
}
type mockSSOMFACeremony struct {
func TestMFACeremony_BrowserMFA(t *testing.T) {
t.Parallel()
ctx := context.Background()
expectedCallbackURL := "http://localhost:12345/?secret=X"
testMFAChallenge := &proto.MFAAuthenticateChallenge{
BrowserMFAChallenge: &proto.BrowserMFAChallenge{
RequestId: "request-id",
},
}
testMFAResponse := &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Browser{
Browser: &proto.BrowserMFAResponse{
RequestId: "request-id",
},
},
}
browserMFACeremony := &mfa.Ceremony{
CreateAuthenticateChallenge: func(ctx context.Context, req *proto.CreateAuthenticateChallengeRequest) (*proto.MFAAuthenticateChallenge, error) {
require.NotNil(t, req)
require.Equal(t, expectedCallbackURL, req.BrowserMFATSHRedirectURL)
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.MFACeremony == nil {
return nil, trace.BadParameter("expected mfa ceremony")
}
return cfg.MFACeremony.Run(ctx, chal)
})
},
MFACeremonyConstructor: func(ctx context.Context) (mfa.CallbackCeremony, error) {
return &mockMFACeremony{
clientCallbackURL: expectedCallbackURL,
prompt: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
return testMFAResponse, nil
},
}, nil
},
}
resp, err := browserMFACeremony.Run(ctx, &proto.CreateAuthenticateChallengeRequest{
ChallengeExtensions: &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
},
MFARequiredCheck: &proto.IsMFARequiredRequest{},
})
require.NoError(t, err)
require.Equal(t, testMFAResponse, resp)
}
type mockMFACeremony struct {
clientCallbackURL string
prompt mfa.PromptFunc
}
// GetClientCallbackURL returns the client callback URL.
func (m *mockSSOMFACeremony) GetClientCallbackURL() string {
func (m *mockMFACeremony) GetClientCallbackURL() string {
return m.clientCallbackURL
}
func (m *mockSSOMFACeremony) GetProxyAddress() string {
func (m *mockMFACeremony) GetProxyAddress() string {
return ""
}
// Run the SSO MFA ceremony.
func (m *mockSSOMFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
func (m *mockMFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
return m.prompt(ctx, chal)
}
func (m *mockSSOMFACeremony) Close() {}
func (m *mockMFACeremony) Close() {}
+4 -4
View File
@@ -54,8 +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
// MFACeremony is an SSO or Browser MFA ceremony.
MFACeremony CallbackCeremony
}
// DeviceDescriptor is a descriptor for a device, such as "registered".
@@ -121,8 +121,8 @@ func WithPromptChallengeExtensions(exts *mfav1.ChallengeExtensions) PromptOpt {
}
// withSSOMFACeremony sets the SSO MFA ceremony for the MFA prompt.
func withSSOMFACeremony(ssoMFACeremony SSOMFACeremony) PromptOpt {
func withSSOMFACeremony(ssoMFACeremony CallbackCeremony) PromptOpt {
return func(cfg *PromptConfig) {
cfg.SSOMFACeremony = ssoMFACeremony
cfg.MFACeremony = ssoMFACeremony
}
}
+31 -41
View File
@@ -238,26 +238,14 @@ func TestCompleteBrowserMFAChallenge(t *testing.T) {
t.Parallel()
ctx := t.Context()
testAuthServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
Dir: t.TempDir(),
})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, testAuthServer.Close()) })
testServer, err := testAuthServer.NewTestTLSServer()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, testServer.Close()) })
a := testServer.Auth()
username := "test-user"
_, _, err = authtest.CreateUserAndRole(a, username, []string{"role"}, nil)
require.NoError(t, err)
env := newBrowserMFATestEnv(t)
a := env.auth
username := env.webauthnUser.GetName()
secretKey, err := secret.NewKey()
require.NoError(t, err)
rawID := []byte("test-raw-id")
rawID := env.webauthnDev.GetWebauthn().CredentialId
webauthnResponse := &wantypes.CredentialAssertionResponse{
PublicKeyCredential: wantypes.PublicKeyCredential{
Credential: wantypes.Credential{
@@ -403,23 +391,16 @@ func TestCreateAuthenticateChallenge_BrowserMFARequestID(t *testing.T) {
t.Parallel()
ctx := t.Context()
testServer, err := authtest.NewTestServer(authtest.ServerConfig{
Auth: authtest.AuthServerConfig{
Dir: t.TempDir(),
},
})
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, testServer.Close()) })
env := newBrowserMFATestEnv(t)
a := env.auth
a := testServer.Auth()
userCreds, err := createUserWithSecondFactors(testServer.TLS)
require.NoError(t, err)
password := []byte("test-password")
require.NoError(t, a.UpsertPassword(env.webauthnUser.GetName(), password))
userCredsRequest := &proto.CreateAuthenticateChallengeRequest_UserCredentials{
UserCredentials: &proto.UserCredentials{
Username: userCreds.username,
Password: userCreds.password,
Username: env.webauthnUser.GetName(),
Password: password,
},
}
@@ -456,13 +437,13 @@ func TestCreateAuthenticateChallenge_BrowserMFARequestID(t *testing.T) {
},
},
{
name: "OK browser MFA challenge extensions applied from SSO MFA session",
name: "OK browser MFA challenge extensions applied from MFA session",
setup: func(t *testing.T) {
session := &services.SSOMFASessionData{
session := &services.MFASessionData{
RequestID: "test-request-1",
Username: userCreds.username,
ConnectorID: "Browser",
ConnectorType: "Browser",
Username: env.webauthnUser.GetName(),
ConnectorID: constants.BrowserMFA,
ConnectorType: constants.BrowserMFA,
ChallengeExtensions: &mfatypes.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
@@ -485,11 +466,11 @@ func TestCreateAuthenticateChallenge_BrowserMFARequestID(t *testing.T) {
{
name: "NOK nil challenge extensions",
setup: func(t *testing.T) {
session := &services.SSOMFASessionData{
session := &services.MFASessionData{
RequestID: "test-request-2",
Username: userCreds.username,
ConnectorID: "Browser",
ConnectorType: "Browser",
Username: env.webauthnUser.GetName(),
ConnectorID: constants.BrowserMFA,
ConnectorType: constants.BrowserMFA,
ChallengeExtensions: nil,
}
err := a.UpsertMFASessionData(ctx, session)
@@ -575,6 +556,16 @@ func TestBrowserMFAChallengeCreation(t *testing.T) {
_, err = a.UpsertSAMLConnector(ctx, samlConnector)
require.NoError(t, err)
samlUser.SetCreatedBy(types.CreatedBy{
Time: env.clock.Now(),
Connector: &types.ConnectorRef{
ID: samlConnector.GetName(),
Type: samlConnector.GetKind(),
},
})
_, err = a.UpsertUser(ctx, samlUser)
require.NoError(t, err)
loginExt := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
}
@@ -679,8 +670,7 @@ func TestBrowserMFAChallengeCreation(t *testing.T) {
require.NotNil(t, chal.BrowserMFAChallenge, "expected Browser MFA challenge to be returned")
assert.NotEmpty(t, chal.BrowserMFAChallenge.RequestId, "request ID should be generated")
// Find SSO MFA session data tied to the challenge.
// Browser MFA reuses the SSO MFA session data storage.
// Find MFA session data tied to the challenge.
sd, err := a.GetSSOMFASessionData(ctx, chal.BrowserMFAChallenge.RequestId)
require.NoError(t, err)
assert.Equal(t, &services.MFASessionData{
@@ -709,7 +699,7 @@ func TestBrowserMFAChallengeCreation(t *testing.T) {
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge) {
require.NotNil(t, chal.BrowserMFAChallenge, "expected Browser MFA challenge to be returned")
// We should find SSO MFA session data tied to the challenge by request ID.
// We should find MFA session data tied to the challenge by request ID.
sd, err := a.GetSSOMFASessionData(ctx, chal.BrowserMFAChallenge.RequestId)
require.NoError(t, err)
assert.Equal(t, mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES, sd.ChallengeExtensions.AllowReuse)
+15 -11
View File
@@ -370,6 +370,9 @@ type Config struct {
// PreferSSO prefers SSO in favor of other MFA methods.
PreferSSO bool
// PreferBrowser prefers browser-based WebAuthn MFA in favor of other MFA methods.
PreferBrowser bool
// CheckVersions will check that client version is compatible
// with auth server version when connecting.
CheckVersions bool
@@ -500,8 +503,8 @@ type Config struct {
// MFAPromptConstructor is a custom MFA prompt constructor to use when prompting for MFA.
MFAPromptConstructor func(cfg *libmfa.PromptConfig) mfa.Prompt
// SSOMFACeremonyConstructor is a custom SSO MFA ceremony constructor.
SSOMFACeremonyConstructor func(rd *sso.Redirector) mfa.SSOMFACeremony
// MFACeremonyConstructor is a custom SSO/Browser MFA ceremony constructor.
MFACeremonyConstructor func(rd *sso.Redirector) mfa.CallbackCeremony
// DisableSSHResumption disables transparent SSH connection resumption.
DisableSSHResumption bool
@@ -3237,7 +3240,7 @@ func (tc *TeleportClient) ConnectToCluster(ctx context.Context) (_ *ClusterClien
return nil, trace.NewAggregate(err, pclt.Close())
}
authClientCfg.MFAPromptConstructor = tc.NewMFAPrompt
authClientCfg.SSOMFACeremonyConstructor = tc.NewSSOMFACeremony
authClientCfg.MFACeremonyConstructor = tc.NewRedirectorMFACeremony
authClient, err := authclient.NewClient(authClientCfg)
if err != nil {
@@ -4220,10 +4223,11 @@ func (tc *TeleportClient) localLogin(ctx context.Context, keyRing *KeyRing, _ co
}
response, err := SSHAgentMFALogin(ctx, SSHLoginMFA{
SSHLogin: sshLogin,
User: tc.Username,
Password: password,
MFAPromptConstructor: tc.NewMFAPrompt,
SSHLogin: sshLogin,
User: tc.Username,
Password: password,
MFAPromptConstructor: tc.NewMFAPrompt,
MFACeremonyConstructor: tc.NewRedirectorMFACeremony,
})
return response, trace.Wrap(err)
}
@@ -5381,10 +5385,10 @@ func (tc *TeleportClient) NewKubernetesServiceClient(ctx context.Context, cluste
Credentials: []client.Credentials{
client.LoadTLS(tlsConfig),
},
ALPNConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired,
InsecureAddressDiscovery: tc.InsecureSkipVerify,
MFAPromptConstructor: tc.NewMFAPrompt,
SSOMFACeremonyConstructor: tc.NewSSOMFACeremony,
ALPNConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired,
InsecureAddressDiscovery: tc.InsecureSkipVerify,
MFAPromptConstructor: tc.NewMFAPrompt,
MFACeremonyConstructor: tc.NewRedirectorMFACeremony,
})
if err != nil {
return nil, trace.Wrap(err)
+14
View File
@@ -151,6 +151,7 @@ func TestTeleportClient_Login_local(t *testing.T) {
authConnector string
allowStdinHijack bool
preferOTP bool
preferBrowser bool
hasTouchIDCredentials bool
authenticatorAttachment wancli.AuthenticatorAttachment
scope string
@@ -316,6 +317,7 @@ func TestTeleportClient_Login_local(t *testing.T) {
tc.AllowStdinHijack = test.allowStdinHijack
tc.AuthConnector = test.authConnector
tc.PreferOTP = test.preferOTP
tc.PreferBrowser = test.preferBrowser
tc.AuthenticatorAttachment = test.authenticatorAttachment
inputReader := test.makeInputReader(password, otpKey, clock)
tc.StdinFunc = func() prompt.StdinReader { return inputReader }
@@ -336,6 +338,16 @@ func TestTeleportClient_Login_local(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Only enable BrowserAuthentication for tests that explicitly request it
if test.preferBrowser != false {
authServer := sa.Auth.GetAuthServer()
authPref, err := authServer.GetAuthPreference(ctx)
require.NoError(t, err)
authPref.SetAllowCLIAuthViaBrowser(true)
_, err = authServer.UpsertAuthPreference(ctx, authPref)
require.NoError(t, err)
}
// Test.
clock.Advance(30 * time.Second)
keyRing, err := tc.Login(ctx)
@@ -598,6 +610,8 @@ func newStandaloneTeleport(t *testing.T, clock clockwork.Clock) *standaloneBundl
Webauthn: &types.Webauthn{
RPID: "localhost",
},
// Disable by default and enable for tests that require it
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
SignatureAlgorithmSuite: types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1,
})
require.NoError(t, err)
+6 -5
View File
@@ -34,7 +34,7 @@ func (tc *TeleportClient) NewMFACeremony() *mfa.Ceremony {
return &mfa.Ceremony{
CreateAuthenticateChallenge: tc.createAuthenticateChallenge,
PromptConstructor: tc.NewMFAPrompt,
SSOMFACeremonyConstructor: tc.NewSSOMFACeremony,
MFACeremonyConstructor: tc.NewRedirectorMFACeremony,
}
}
@@ -64,6 +64,7 @@ func (tc *TeleportClient) NewMFAPrompt(opts ...mfa.PromptOpt) mfa.Prompt {
Writer: tc.Stderr,
PreferOTP: tc.PreferOTP,
PreferSSO: tc.PreferSSO,
PreferBrowser: tc.PreferBrowser,
AllowStdinHijack: tc.AllowStdinHijack,
StdinFunc: tc.StdinFunc,
})
@@ -86,8 +87,8 @@ func (tc *TeleportClient) newPromptConfig(opts ...mfa.PromptOpt) *libmfa.PromptC
return cfg
}
// NewSSOMFACeremony creates a new SSO MFA ceremony.
func (tc *TeleportClient) NewSSOMFACeremony(ctx context.Context) (mfa.SSOMFACeremony, error) {
// NewRedirectorMFACeremony creates a new redirector for SSO or Browser MFA ceremony.
func (tc *TeleportClient) NewRedirectorMFACeremony(ctx context.Context) (mfa.CallbackCeremony, error) {
rdConfig, err := tc.ssoRedirectorConfig(ctx, "" /*connectorDisplayName*/)
if err != nil {
return nil, trace.Wrap(err)
@@ -98,8 +99,8 @@ func (tc *TeleportClient) NewSSOMFACeremony(ctx context.Context) (mfa.SSOMFACere
return nil, trace.Wrap(err, "failed to create a redirector for SSO MFA")
}
if tc.SSOMFACeremonyConstructor != nil {
return tc.SSOMFACeremonyConstructor(rd), nil
if tc.MFACeremonyConstructor != nil {
return tc.MFACeremonyConstructor(rd), nil
}
return sso.NewCLIMFACeremony(rd), nil
+251 -89
View File
@@ -19,6 +19,7 @@
package mfa
import (
"cmp"
"context"
"fmt"
"io"
@@ -46,6 +47,8 @@ const (
cliMFATypeWebauthn = "WEBAUTHN"
// cliMFATypeSSO is the CLI display name for SSO.
cliMFATypeSSO = "SSO"
// cliMFATypeBrowserMFA is the CLI display name for Browser MFA.
cliMFATypeBrowserMFA = "BROWSER"
)
// CLIPromptConfig contains CLI prompt config options.
@@ -65,9 +68,14 @@ type CLIPromptConfig struct {
// PreferSSO favors SSO challenges, if applicable.
// Takes precedence over AuthenticatorAttachment settings.
PreferSSO bool
// PreferBrowser favors browser-based WebAuthn challenges, if applicable.
// Takes precedence over AuthenticatorAttachment settings.
PreferBrowser bool
// StdinFunc allows tests to override prompt.Stdin().
// If nil prompt.Stdin() is used.
StdinFunc func() prompt.StdinReader
// RuntimeOS overrides runtime.GOOS. Intended for tests only.
RuntimeOS string
}
// CLIPrompt is the default CLI mfa prompt implementation.
@@ -100,90 +108,79 @@ func (c *CLIPrompt) writer() io.Writer {
return c.cfg.Writer
}
// Run prompts the user to complete an MFA authentication challenge.
func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
if c.cfg.PromptReason != "" {
fmt.Fprintln(c.writer(), c.cfg.PromptReason)
}
func (c *CLIPrompt) getOS() string {
return cmp.Or(c.cfg.RuntimeOS, runtime.GOOS)
}
promptOTP := chal.TOTP != nil
promptWebauthn := chal.WebauthnChallenge != nil
promptSSO := chal.SSOChallenge != nil
// mfaPromptState represents which MFA methods are available to prompt.
type mfaPromptState struct {
promptWebauthn bool
promptSSO bool
promptOTP bool
promptBrowser bool
}
// No prompt to run, no-op.
if !promptOTP && !promptWebauthn && !promptSSO {
return &proto.MFAAuthenticateResponse{}, nil
}
isPerSessionMFA := c.cfg.Extensions.GetScope() == mfav1.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION
var availableMethods []string
if promptWebauthn {
availableMethods = append(availableMethods, cliMFATypeWebauthn)
}
if promptSSO {
availableMethods = append(availableMethods, cliMFATypeSSO)
}
if promptOTP && !isPerSessionMFA {
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 promptSSO && c.cfg.SSOMFACeremony == nil {
promptSSO = false
slog.DebugContext(ctx, "SSO MFA not supported by this client, this is likely a bug")
}
// Short circuit if OTP was preferred by --mfa-mode during per-session MFA
if c.cfg.PreferOTP && promptOTP && isPerSessionMFA {
return nil, trace.AccessDenied("only WebAuthn and SSO MFA methods are supported with per-session MFA, can not specify --mfa-mode=otp")
}
// Prefer whatever method is requested by the client.
// filterMFAMethods determines which MFA method(s) to prompt for based on available methods,
// user preferences, and configuration. It prints a message to the user if multiple methods
// are available and returns the filtered state and list of available methods.
func (c *CLIPrompt) filterMFAMethods(state mfaPromptState, isPerSessionMFA bool, availableMethods []string) (mfaPromptState, bool) {
var chosenMethods []string
var userSpecifiedMethod bool
// Prefer whatever method is requested by the client.
switch {
case c.cfg.PreferSSO && promptSSO:
chosenMethods = []string{cliMFATypeSSO}
promptWebauthn, promptOTP = false, false
case c.cfg.PreferBrowser && state.promptBrowser:
chosenMethods = []string{cliMFATypeBrowserMFA}
state.promptWebauthn, state.promptOTP, state.promptSSO = false, false, false
userSpecifiedMethod = true
case c.cfg.PreferOTP && promptOTP:
case c.cfg.PreferSSO && state.promptSSO:
chosenMethods = []string{cliMFATypeSSO}
state.promptWebauthn, state.promptOTP, state.promptBrowser = false, false, false
userSpecifiedMethod = true
case c.cfg.PreferOTP && state.promptOTP:
chosenMethods = []string{cliMFATypeOTP}
promptWebauthn, promptSSO = false, false
state.promptWebauthn, state.promptSSO, state.promptBrowser = false, false, false
userSpecifiedMethod = true
case c.cfg.AuthenticatorAttachment != wancli.AttachmentAuto:
chosenMethods = []string{cliMFATypeWebauthn}
promptSSO, promptOTP = false, false
state.promptSSO, state.promptOTP, state.promptBrowser = false, false, false
userSpecifiedMethod = true
}
// Use stronger auth methods if hijack is not allowed.
if !c.cfg.AllowStdinHijack && promptWebauthn {
promptOTP = false
if !c.cfg.AllowStdinHijack && state.promptWebauthn {
state.promptOTP = false
}
// 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)
// If no user preference was specified, set initial MFA preference
if !userSpecifiedMethod {
// We should never prompt for OTP when per-session MFA is enabled. As long as other MFA methods are available,
// we can completely ignore OTP. The promptOTP case in [Run] will return an error in the case that no other methods
// are available.
if isPerSessionMFA && (state.promptWebauthn || state.promptSSO || state.promptBrowser) {
state.promptOTP = false
}
// Determine initial method to show based on MFA hierarchy:
// Webauthn > SSO > Browser > OTP.
switch {
case state.promptWebauthn:
chosenMethods = []string{cliMFATypeWebauthn}
// Allow dual prompt with OTP.
if state.promptOTP {
chosenMethods = append(chosenMethods, cliMFATypeOTP)
}
case state.promptSSO:
chosenMethods = []string{cliMFATypeSSO}
case state.promptBrowser:
chosenMethods = []string{cliMFATypeBrowserMFA}
case state.promptOTP:
chosenMethods = []string{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 there are multiple options and we chose fewer without explicit user preference,
// notify the user about the available methods and how to select a specific one.
if len(availableMethods) > len(chosenMethods) && len(chosenMethods) > 0 && !userSpecifiedMethod {
availableMethodsString := strings.ToLower(strings.Join(availableMethods, ","))
const msg = "" +
@@ -192,32 +189,184 @@ func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng
fmt.Fprintf(c.writer(), msg, strings.Join(availableMethods, ", "), strings.Join(chosenMethods, " and "), availableMethodsString, availableMethodsString)
}
// We should never prompt for OTP when per-session MFA is enabled. As long as other MFA methods are available,
// we can completely ignore OTP. The promptOTP case below will return an error in the case that no other methods
// are available.
if isPerSessionMFA && (promptWebauthn || promptSSO) {
promptOTP = false
return state, userSpecifiedMethod
}
// Run prompts the user to complete an MFA authentication challenge.
func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
if c.cfg.PromptReason != "" {
fmt.Fprintln(c.writer(), c.cfg.PromptReason)
}
switch {
case promptOTP && promptWebauthn:
resp, err := c.promptWebauthnAndOTP(ctx, chal)
return resp, trace.Wrap(err)
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:
if isPerSessionMFA {
return nil, trace.AccessDenied("only WebAuthn and SSO MFA methods are supported with per-session MFA")
// Initialize prompt state from the challenge.
state := mfaPromptState{
promptOTP: chal.TOTP != nil,
promptWebauthn: chal.WebauthnChallenge != nil,
promptSSO: chal.SSOChallenge != nil,
promptBrowser: chal.BrowserMFAChallenge != nil,
}
// No prompt to run, no-op.
if !state.promptOTP && !state.promptWebauthn && !state.promptSSO && !state.promptBrowser {
return &proto.MFAAuthenticateResponse{}, nil
}
isPerSessionMFA := c.cfg.Extensions.GetScope() == mfav1.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION
// Build list of available methods from the challenge before filtering
// out unsupported methods. This list is used in user-facing messages.
var availableMethods []string
if state.promptWebauthn {
availableMethods = append(availableMethods, cliMFATypeWebauthn)
}
if state.promptSSO {
availableMethods = append(availableMethods, cliMFATypeSSO)
}
if state.promptOTP && !isPerSessionMFA {
availableMethods = append(availableMethods, cliMFATypeOTP)
}
if state.promptBrowser {
availableMethods = append(availableMethods, cliMFATypeBrowserMFA)
}
// Check off unsupported methods.
if state.promptWebauthn && !c.cfg.WebauthnSupported {
state.promptWebauthn = false
slog.DebugContext(ctx, "Disabling WebAuthn: hardware device MFA not supported by your platform")
}
if state.promptSSO && c.cfg.MFACeremony == nil {
state.promptSSO = false
slog.DebugContext(ctx, "Disabling SSO MFA: SSO MFA ceremony not available (this is likely a bug)")
}
if state.promptBrowser && (!c.cfg.WebauthnSupported || c.cfg.MFACeremony == nil) {
state.promptBrowser = false
slog.DebugContext(
ctx,
"Disabling Browser MFA: cluster needs to support Webauthn and client needs to support SSO MFA Ceremony",
"webauthn_supported", c.cfg.WebauthnSupported,
"mfa_ceremony_available (if false, this is a bug)", c.cfg.MFACeremony != nil,
)
}
// Short circuit if OTP was preferred by --mfa-mode during per-session MFA.
if c.cfg.PreferOTP && state.promptOTP && isPerSessionMFA {
return nil, trace.AccessDenied("only WebAuthn, SSO MFA, and Browser MFA methods are supported with per-session MFA, cannot specify --mfa-mode=otp")
}
// Determine which method(s) to use and print options if multiple are available.
var userSpecifiedMethod bool
state, userSpecifiedMethod = c.filterMFAMethods(state, isPerSessionMFA, availableMethods)
// Perform MFA with automatic fallback to other methods on failure.
// In order: WebAuthn > SSO > Browser MFA > OTP
return c.promptWithFallback(ctx, chal, state, availableMethods, isPerSessionMFA, userSpecifiedMethod)
}
func (c *CLIPrompt) promptWithFallback(ctx context.Context, chal *proto.MFAAuthenticateChallenge, state mfaPromptState, availableMethods []string, isPerSessionMFA, userSpecifiedMethod bool) (*proto.MFAAuthenticateResponse, error) {
var lastErr error
// If the user is running Windows and hasn't marked Browser MFA as preferred,
// skip Browser MFA. They will have access to the same MFA methods using the
// WebAuthn.dll prompt.
skipBrowserMFAFallback := false
if state.promptBrowser && !c.cfg.PreferBrowser && c.getOS() == constants.WindowsOS {
skipBrowserMFAFallback = true
slog.DebugContext(ctx, "Skipping Browser MFA fallback on Windows (WebAuthn.dll provides same functionality)")
}
// Retry loop for fallback behavior.
for {
// Determine current method to try based on priority order.
var currentMethod string
switch {
case state.promptWebauthn:
currentMethod = cliMFATypeWebauthn
case state.promptSSO:
currentMethod = cliMFATypeSSO
case state.promptBrowser && !skipBrowserMFAFallback:
currentMethod = cliMFATypeBrowserMFA
case state.promptOTP:
currentMethod = cliMFATypeOTP
default:
// No more methods to try.
slog.DebugContext(ctx, "No more MFA methods to try",
"last_error", lastErr,
"available_methods", strings.Join(availableMethods, ", "),
)
if lastErr != nil {
return nil, trace.Wrap(lastErr)
}
return nil, trace.BadParameter("client does not support any available MFA methods [%v], see debug logs for details", strings.Join(availableMethods, ", "))
}
resp, err := c.promptOTP(ctx, c.cfg.Quiet)
return resp, trace.Wrap(err)
default:
return nil, trace.BadParameter("client does not support any available MFA methods [%v], see debug logs for details", strings.Join(availableMethods, ", "))
// If we're retrying after a failure, inform the user.
if lastErr != nil {
fmt.Fprintf(c.writer(), "Attempting MFA authentication with %s\n", currentMethod)
}
// Perform the chosen ceremony based on the filtered state.
var resp *proto.MFAAuthenticateResponse
var err error
switch {
case state.promptWebauthn:
if state.promptOTP {
resp, err = c.promptWebauthnAndOTP(ctx, chal)
} else {
resp, err = c.promptWebauthn(ctx, chal, c.getWebauthnPrompt(ctx))
}
case state.promptSSO:
resp, err = c.promptSSO(ctx, chal)
case state.promptBrowser && !skipBrowserMFAFallback:
resp, err = c.promptBrowser(ctx, chal)
case state.promptOTP:
if isPerSessionMFA {
return nil, trace.AccessDenied("only WebAuthn, SSO MFA, and Browser MFA methods are supported with per-session MFA")
}
resp, err = c.promptOTP(ctx, c.cfg.Quiet)
}
// MFA successful
if err == nil {
slog.DebugContext(ctx, "MFA authentication successful", "method", currentMethod)
return resp, nil
}
slog.ErrorContext(ctx, "MFA authentication failed",
"method", currentMethod,
"error", err,
"user_specified", userSpecifiedMethod,
)
// Don't fall back if the user explicitly chose this method.
if userSpecifiedMethod {
return nil, trace.Wrap(err)
}
// Print error message about the failure.
fmt.Fprintf(c.writer(), "MFA authentication with %s failed, check logs for details\n", currentMethod)
// Don't fall back if the context is done (e.g. user canceled or request timed out).
if ctx.Err() != nil {
return nil, trace.Wrap(err)
}
// Disable the failed method and loop to try the next one.
// Fallback only moves forward in priority order: WebAuthn > SSO > Browser MFA > OTP
switch currentMethod {
case cliMFATypeWebauthn:
state.promptWebauthn = false
case cliMFATypeSSO:
state.promptSSO = false
case cliMFATypeBrowserMFA:
state.promptBrowser = false
case cliMFATypeOTP:
state.promptOTP = false
}
lastErr = err
}
}
@@ -272,7 +421,7 @@ func (c *CLIPrompt) promptDevicePrefix() string {
func (c *CLIPrompt) promptWebauthnAndOTP(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
spawnGoroutines := func(ctx context.Context, wg *sync.WaitGroup, respC chan<- MFAGoroutineResponse) {
var message string
if runtime.GOOS == constants.WindowsOS {
if c.getOS() == constants.WindowsOS {
message = "Follow the OS dialogs for platform authentication, or enter an OTP code here:"
webauthnwin.SetPromptPlatformMessage("")
} else {
@@ -372,6 +521,19 @@ func (w *webauthnPromptWithOTP) PromptPIN() (string, error) {
}
func (c *CLIPrompt) promptSSO(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
resp, err := c.cfg.SSOMFACeremony.Run(ctx, chal)
// MFACeremony.Run can handle either SSO or Browser MFA. It defaults to SSO MFA,
// but to be safe, copy and remove the Browser MFA challenge here.
ssoChal := *chal
ssoChal.BrowserMFAChallenge = nil
resp, err := c.cfg.MFACeremony.Run(ctx, &ssoChal)
return resp, trace.Wrap(err)
}
func (c *CLIPrompt) promptBrowser(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
// MFACeremony.Run can handle either SSO or Browser MFA. It defaults to SSO MFA,
// so remove copy and remove the SSO challenge so Browser MFA is used.
browserChal := *chal
browserChal.SSOChallenge = nil
resp, err := c.cfg.MFACeremony.Run(ctx, &browserChal)
return resp, trace.Wrap(err)
}
+114 -8
View File
@@ -29,6 +29,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/constants"
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
webauthnpb "github.com/gravitational/teleport/api/types/webauthn"
"github.com/gravitational/teleport/api/utils/prompt"
@@ -155,6 +156,33 @@ func TestCLIPrompt(t *testing.T) {
},
},
},
{
name: "OK prefer browser when specified",
expectStdOut: "", // stdout is handled internally in the MFA ceremony, which is mocked in this test.
challenge: &proto.MFAAuthenticateChallenge{
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
TOTP: &proto.TOTPChallenge{},
SSOChallenge: &proto.SSOChallenge{},
BrowserMFAChallenge: &proto.BrowserMFAChallenge{},
},
modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) {
cfg.PreferBrowser = true
cfg.MFACeremony = &mockSSOMFACeremony{
runFunc: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
return &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Browser{
Browser: &proto.BrowserMFAResponse{RequestId: "request-id"},
},
}, nil
},
}
},
expectResp: &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Browser{
Browser: &proto.BrowserMFAResponse{RequestId: "request-id"},
},
},
},
{
name: "OK prefer webauthn over sso",
expectStdOut: "" +
@@ -270,7 +298,7 @@ func TestCLIPrompt(t *testing.T) {
},
{
name: "NOK no webauthn response",
expectStdOut: "Tap any security key\n",
expectStdOut: "Tap any security key\nMFA authentication with WEBAUTHN failed, check logs for details\n",
challenge: &proto.MFAAuthenticateChallenge{
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
},
@@ -278,7 +306,7 @@ func TestCLIPrompt(t *testing.T) {
},
{
name: "NOK no sso response",
expectStdOut: "",
expectStdOut: "MFA authentication with SSO failed, check logs for details\n",
challenge: &proto.MFAAuthenticateChallenge{
SSOChallenge: &proto.SSOChallenge{},
},
@@ -286,7 +314,7 @@ func TestCLIPrompt(t *testing.T) {
},
{
name: "NOK no otp response",
expectStdOut: "Enter an OTP code from a device:\n",
expectStdOut: "Enter an OTP code from a device:\nMFA authentication with OTP failed, check logs for details\n",
challenge: &proto.MFAAuthenticateChallenge{
TOTP: &proto.TOTPChallenge{},
},
@@ -294,7 +322,7 @@ func TestCLIPrompt(t *testing.T) {
},
{
name: "NOK no webauthn or otp response",
expectStdOut: "Tap any security key or enter a code from a OTP device\n",
expectStdOut: "Tap any security key or enter a code from a OTP device\nMFA authentication with WEBAUTHN failed, check logs for details\n",
challenge: &proto.MFAAuthenticateChallenge{
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
TOTP: &proto.TOTPChallenge{},
@@ -411,7 +439,7 @@ Enter your security key PIN:
},
modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) {
cfg.WebauthnSupported = false
cfg.SSOMFACeremony = nil
cfg.MFACeremony = nil
},
expectErr: trace.BadParameter("client does not support any available MFA methods [WEBAUTHN, SSO], see debug logs for details"),
},
@@ -425,7 +453,7 @@ Enter your security key PIN:
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION,
}
},
expectErr: trace.AccessDenied("only WebAuthn and SSO MFA methods are supported with per-session MFA"),
expectErr: trace.AccessDenied("only WebAuthn, SSO MFA, and Browser MFA methods are supported with per-session MFA"),
},
{
name: "NOK prefer otp with per-session MFA",
@@ -438,7 +466,7 @@ Enter your security key PIN:
}
cfg.PreferOTP = true
},
expectErr: trace.AccessDenied("only WebAuthn and SSO MFA methods are supported with per-session MFA, can not specify --mfa-mode=otp"),
expectErr: trace.AccessDenied("only WebAuthn, SSO MFA, and Browser MFA methods are supported with per-session MFA, cannot specify --mfa-mode=otp"),
},
{
name: "OK webauthn or otp with stdin hijack and per-session MFA, no choice presented",
@@ -500,6 +528,80 @@ Enter your security key PIN:
},
},
},
{
name: "NOK browser fallback skipped on windows when not preferred",
expectStdOut: "" +
"Available MFA methods [WEBAUTHN, BROWSER]. Continuing with WEBAUTHN.\n" +
"If you wish to perform MFA with another method, specify with flag --mfa-mode=<webauthn,browser> or environment variable TELEPORT_MFA_MODE=<webauthn,browser>.\n\n" +
"Tap any security key\n" +
"MFA authentication with WEBAUTHN failed, check logs for details\n",
challenge: &proto.MFAAuthenticateChallenge{
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
BrowserMFAChallenge: &proto.BrowserMFAChallenge{},
},
makeWebauthnLoginFunc: func(_ *prompt.FakeReader) mfa.WebauthnLoginFunc {
return func(ctx context.Context, origin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, opts *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
if _, err := prompt.PromptTouch(); err != nil {
return nil, "", trace.Wrap(err)
}
return nil, "", context.DeadlineExceeded
}
},
modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) {
cfg.RuntimeOS = constants.WindowsOS
cfg.MFACeremony = &mockSSOMFACeremony{
runFunc: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
return &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Browser{
Browser: &proto.BrowserMFAResponse{RequestId: "request-id"},
},
}, nil
},
}
},
expectErr: context.DeadlineExceeded,
},
{
name: "OK prompt fallback webauthn > SSO > browser MFA",
expectStdOut: "" +
"Available MFA methods [WEBAUTHN, BROWSER]. Continuing with WEBAUTHN.\n" +
"If you wish to perform MFA with another method, specify with flag --mfa-mode=<webauthn,browser> or environment variable TELEPORT_MFA_MODE=<webauthn,browser>.\n\n" +
"Tap any security key\n" +
"MFA authentication with WEBAUTHN failed, check logs for details\n" +
"Attempting MFA authentication with BROWSER\n",
challenge: &proto.MFAAuthenticateChallenge{
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
BrowserMFAChallenge: &proto.BrowserMFAChallenge{},
},
makeWebauthnLoginFunc: func(_ *prompt.FakeReader) mfa.WebauthnLoginFunc {
return func(ctx context.Context, origin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, opts *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
if _, err := prompt.PromptTouch(); err != nil {
return nil, "", trace.Wrap(err)
}
return nil, "", errors.New("webauthn device not found")
}
},
modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) {
cfg.MFACeremony = &mockSSOMFACeremony{
runFunc: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
return &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Browser{
Browser: &proto.BrowserMFAResponse{
RequestId: "request-id",
},
},
}, nil
},
}
},
expectResp: &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Browser{
Browser: &proto.BrowserMFAResponse{
RequestId: "request-id",
},
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
@@ -529,7 +631,7 @@ Enter your security key PIN:
}
}
cfg.SSOMFACeremony = &mockSSOMFACeremony{
cfg.MFACeremony = &mockSSOMFACeremony{
mfaResp: tc.expectResp,
}
@@ -563,6 +665,7 @@ Enter your security key PIN:
type mockSSOMFACeremony struct {
mfaResp *proto.MFAAuthenticateResponse
runFunc func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error)
}
func (m *mockSSOMFACeremony) GetClientCallbackURL() string {
@@ -575,6 +678,9 @@ func (m *mockSSOMFACeremony) GetProxyAddress() string {
// Run the SSO MFA ceremony.
func (m *mockSSOMFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
if m.runFunc != nil {
return m.runFunc(ctx, chal)
}
if m.mfaResp == nil {
return nil, context.DeadlineExceeded
}
+5 -3
View File
@@ -65,8 +65,9 @@ func TestPromptMFAChallenge_usingNonRegisteredDevice(t *testing.T) {
}
challengeWebauthnOTP := &proto.MFAAuthenticateChallenge{
TOTP: &proto.TOTPChallenge{}, // non-nil enables OTP prompt
WebauthnChallenge: challengeWebauthnOnly.WebauthnChallenge,
TOTP: &proto.TOTPChallenge{}, // non-nil enables OTP prompt
WebauthnChallenge: challengeWebauthnOnly.WebauthnChallenge,
BrowserMFAChallenge: nil,
}
tests := []struct {
@@ -82,7 +83,8 @@ func TestPromptMFAChallenge_usingNonRegisteredDevice(t *testing.T) {
name: "webauthn and OTP",
challenge: challengeWebauthnOTP,
customizePrompt: func(p *mfa.CLIPromptConfig) {
p.AllowStdinHijack = true // required for OTP+WebAuthn prompt.
// Specify cross-platform WebAuthn to prevent fallback to Browser MFA.
p.AuthenticatorAttachment = wancli.AttachmentCrossPlatform
},
},
}
+1 -1
View File
@@ -77,7 +77,7 @@ func RunPresenceTask(ctx context.Context, term io.Writer, maintainer PresenceMai
}
presenceCeremony := &mfa.Ceremony{
SSOMFACeremonyConstructor: baseCeremony.SSOMFACeremonyConstructor,
MFACeremonyConstructor: baseCeremony.MFACeremonyConstructor,
PromptConstructor: func(opts ...mfa.PromptOpt) mfa.Prompt {
return mfa.PromptFunc(func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
// Replace normal output with terminal messages specific to moderated sessions.
+6 -1
View File
@@ -121,6 +121,11 @@ func (m *MFACeremony) GetProxyAddress() string {
// Run the SSO MFA ceremony.
func (m *MFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
// TODO(danielashare): Remove when Browser MFA challenge handling is implemented
if chal.SSOChallenge == nil {
return nil, trace.BadParameter("no SSO challenge provided")
}
if err := m.HandleRedirect(ctx, chal.SSOChallenge.RedirectUrl); err != nil {
return nil, trace.Wrap(err)
}
@@ -171,7 +176,7 @@ func NewCLIMFACeremony(rd *Redirector) *MFACeremony {
}
// NewConnectMFACeremony creates a new Teleport Connect SSO ceremony from the given redirector.
func NewConnectMFACeremony(rd *Redirector) mfa.SSOMFACeremony {
func NewConnectMFACeremony(rd *Redirector) mfa.CallbackCeremony {
return &MFACeremony{
close: rd.Close,
ClientCallbackURL: rd.ClientCallbackURL,
+3
View File
@@ -94,6 +94,9 @@ const (
// on this page in order to capture the SSO MFA response regardless of what page the challenge
// was requested from.
WebMFARedirect = "/web/sso_confirm"
// WebBrowserMFAPath is the path for browser-based MFA flows.
WebBrowserMFAPath = "/web/mfa/browser/"
)
// RedirectorConfig is configuration for an sso redirector.
+22 -1
View File
@@ -361,6 +361,9 @@ type SSHLoginMFA struct {
SSHLogin
// MFAPromptConstructor is a custom MFA prompt constructor to use when prompting for MFA.
MFAPromptConstructor mfa.PromptConstructor
// MFACeremonyConstructor is an optional MFA ceremony constructor.
// Currently used for Browser MFA during the login process.
MFACeremonyConstructor mfa.MFACeremonyConstructor
// User is the login username.
User string
// Password is the login password.
@@ -409,6 +412,8 @@ type MFAAuthenticateChallenge struct {
TOTPChallenge bool `json:"totp_challenge"`
// SSOChallenge is an SSO MFA challenge.
SSOChallenge *SSOChallenge `json:"sso_challenge"`
// BrowserMFAChallenge is a Browser MFA challenge.
BrowserMFAChallenge *BrowserMFAChallenge `json:"browser_challenge"`
}
// SSOChallenge is a json compatible [proto.SSOChallenge].
@@ -453,6 +458,18 @@ type TOTPRegisterChallenge struct {
QRCode []byte `json:"qrCode"`
}
// BrowserMFAChallenge is a json compatible [proto.BrowserMFAChallenge].
type BrowserMFAChallenge struct {
RequestID string `json:"requestId,omitempty"`
}
// BrowserChallengeToProto converts an BrowserChallenge to proto format.
func BrowserChallengeToProto(browserChal *BrowserMFAChallenge) *proto.BrowserMFAChallenge {
return &proto.BrowserMFAChallenge{
RequestId: browserChal.RequestID,
}
}
// initClient creates a new client to the HTTPS web proxy.
func initClient(proxyAddr string, insecure bool, pool *x509.CertPool, extraHeaders map[string]string, opts ...roundtrip.ClientParam) (*WebClient, *url.URL, error) {
log := slog.With(teleport.ComponentKey, teleport.ComponentClient)
@@ -688,6 +705,9 @@ func newMFALoginCeremony(clt *WebClient, login SSHLoginMFA) *mfa.Ceremony {
User: login.User,
Pass: login.Password,
}
if req != nil && req.BrowserMFATSHRedirectURL != "" {
beginReq.BrowserMFATSHRedirectURL = req.BrowserMFATSHRedirectURL
}
challengeJSON, err := clt.PostJSON(ctx, clt.Endpoint("webapi", "mfa", "login", "begin"), beginReq)
if err != nil {
return nil, trace.Wrap(err)
@@ -708,7 +728,8 @@ func newMFALoginCeremony(clt *WebClient, login SSHLoginMFA) *mfa.Ceremony {
}
return chal, nil
},
PromptConstructor: login.MFAPromptConstructor,
PromptConstructor: login.MFAPromptConstructor,
MFACeremonyConstructor: login.MFACeremonyConstructor,
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ type CreateGatewayParams struct {
// CreateGateway creates a gateway
func (c *Cluster) CreateGateway(ctx context.Context, params CreateGatewayParams) (gateway.Gateway, error) {
c.clusterClient.MFAPromptConstructor = params.MFAPromptConstructor
c.clusterClient.SSOMFACeremonyConstructor = sso.NewConnectMFACeremony
c.clusterClient.MFACeremonyConstructor = sso.NewConnectMFACeremony
switch {
case params.TargetURI.IsDB():
+1 -1
View File
@@ -304,7 +304,7 @@ func (s *Service) ResolveClusterURI(uri uri.ResourceURI) (*clusters.Cluster, *cl
// Custom MFAPromptConstructor gets removed during the calls to Login and LoginPasswordless RPCs.
// Those RPCs assume that the default CLI prompt is in use.
clusterClient.MFAPromptConstructor = s.NewMFAPromptConstructor(cluster.URI)
clusterClient.SSOMFACeremonyConstructor = sso.NewConnectMFACeremony
clusterClient.MFACeremonyConstructor = sso.NewConnectMFACeremony
return cluster, clusterClient, nil
}
+11 -2
View File
@@ -74,7 +74,16 @@ func (s *Service) promptAppMFA(ctx context.Context, in *api.PromptMFARequest) (*
func (p *mfaPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
promptOTP := chal.TOTP != nil
promptWebauthn := chal.WebauthnChallenge != nil && p.cfg.WebauthnSupported
promptSSO := chal.SSOChallenge != nil && p.cfg.SSOMFACeremony != nil
promptSSO := chal.SSOChallenge != nil && p.cfg.MFACeremony != nil
promptBrowser := chal.BrowserMFAChallenge != nil
// TODO(danielashare): Implement Browser MFA for connect
if promptBrowser && !promptOTP && !promptWebauthn && !promptSSO {
return nil, trace.AccessDenied(
"Browser MFA was the only challenge returned and is not supported in Connect yet",
)
}
scope := p.cfg.Extensions.GetScope()
// No prompt to run, no-op.
if !promptOTP && !promptWebauthn && !promptSSO {
@@ -166,6 +175,6 @@ func (p *mfaPrompt) promptWebauthn(ctx context.Context, chal *proto.MFAAuthentic
}
func (c *mfaPrompt) promptSSO(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
resp, err := c.cfg.SSOMFACeremony.Run(ctx, chal)
resp, err := c.cfg.MFACeremony.Run(ctx, chal)
return resp, trace.Wrap(err)
}
+1 -1
View File
@@ -624,7 +624,7 @@ func (h *Handler) performSessionMFACeremony(
mfaCeremony := &mfa.Ceremony{
CreateAuthenticateChallenge: sctx.cfg.RootClient.CreateAuthenticateChallenge,
SSOMFACeremonyConstructor: func(_ context.Context) (mfa.SSOMFACeremony, error) {
MFACeremonyConstructor: func(_ context.Context) (mfa.CallbackCeremony, error) {
u, err := url.Parse(sso.WebMFARedirect)
if err != nil {
return nil, trace.Wrap(err)
+1 -1
View File
@@ -632,7 +632,7 @@ func newMFACeremony(stream *terminal.WSStream, createAuthenticateChallenge mfa.C
return &mfa.Ceremony{
CreateAuthenticateChallenge: createAuthenticateChallenge,
SSOMFACeremonyConstructor: func(ctx context.Context) (mfa.SSOMFACeremony, error) {
MFACeremonyConstructor: func(ctx context.Context) (mfa.CallbackCeremony, error) {
u, err := url.Parse(sso.WebMFARedirect)
if err != nil {
+10 -2
View File
@@ -39,6 +39,7 @@ import (
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/tool/common"
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
tctlmfa "github.com/gravitational/teleport/tool/tctl/common/mfa"
)
// InitFunc initiates connection to auth service, makes ping request and return the client instance.
@@ -98,13 +99,20 @@ func GetInitFunc(ccf tctlcfg.GlobalCLIFlags, cfg *servicecfg.Config) InitFunc {
return nil, nil, trace.NewAggregate(err, client.Close())
}
proxyAddr := resp.ProxyPublicAddr
mfaOpts, err := tctlmfa.ParseMFAMode(ccf.MFAMode)
if err != nil {
return nil, nil, trace.Wrap(err)
}
client.SetMFAPromptConstructor(func(opts ...mfa.PromptOpt) mfa.Prompt {
promptCfg := libmfa.NewPromptConfig(proxyAddr, opts...)
promptCfg.AuthenticatorAttachment = mfaOpts.AuthenticatorAttachment
return libmfa.NewCLIPrompt(&libmfa.CLIPromptConfig{
PromptConfig: *promptCfg,
PromptConfig: *promptCfg,
PreferSSO: mfaOpts.PreferSSO,
PreferBrowser: mfaOpts.PreferBrowser,
})
})
client.SetSSOMFACeremonyConstructor(func(ctx context.Context) (mfa.SSOMFACeremony, error) {
client.SetMFACeremonyConstructor(func(ctx context.Context) (mfa.CallbackCeremony, error) {
rdConfig := sso.RedirectorConfig{
ProxyAddr: proxyAddr,
}
+2
View File
@@ -56,6 +56,8 @@ type GlobalCLIFlags struct {
// Insecure, when set, skips validation of server TLS certificate when
// connecting through a proxy (specified in AuthServerAddr).
Insecure bool
// MFAMode is the preferred mode for MFA assertions.
MFAMode string
}
// ApplyConfig takes configuration values from the config file and applies them
+64
View File
@@ -0,0 +1,64 @@
// Teleport
// Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
package mfa
import (
"fmt"
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
)
const (
// mfaModeAuto automatically chooses the best MFA device(s), without any
// restrictions.
MFAModeAuto = "auto"
// MFAModeCrossPlatform utilizes only cross-platform devices, such as
// pluggable hardware keys.
// Implies Webauthn.
MFAModeCrossPlatform = "cross-platform"
// MFAModePlatform utilizes only platform devices, such as Touch ID.
// Implies Webauthn.
MFAModePlatform = "platform"
// MFAModeSSO utilizes only SSO devices.
MFAModeSSO = "sso"
// MFAModeBrowser utilizes browser-based WebAuthn MFA.
MFAModeBrowser = "browser"
)
type MFAModeOpts struct {
AuthenticatorAttachment wancli.AuthenticatorAttachment
PreferSSO bool
PreferBrowser bool
}
func ParseMFAMode(mode string) (*MFAModeOpts, error) {
opts := &MFAModeOpts{}
switch mode {
case "", MFAModeAuto:
case MFAModeCrossPlatform:
opts.AuthenticatorAttachment = wancli.AttachmentCrossPlatform
case MFAModePlatform:
opts.AuthenticatorAttachment = wancli.AttachmentPlatform
case MFAModeSSO:
opts.PreferSSO = true
case MFAModeBrowser:
opts.PreferBrowser = true
default:
return nil, fmt.Errorf("invalid MFA mode: %q", mode)
}
return opts, nil
}
+7
View File
@@ -41,6 +41,7 @@ import (
"github.com/gravitational/teleport/tool/common"
commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
"github.com/gravitational/teleport/tool/tctl/common/mfa"
)
const (
@@ -52,6 +53,7 @@ const (
const (
identityFileEnvVar = "TELEPORT_IDENTITY_FILE"
authAddrEnvVar = "TELEPORT_AUTH_SERVER"
mfaModeEnvVar = "TELEPORT_MFA_MODE"
)
// CLICommand interface must be implemented by every CLI command
@@ -161,6 +163,11 @@ func TryRun(ctx context.Context, commands []CLICommand, args []string) error {
StringVar(&ccf.IdentityFilePath)
app.Flag("insecure", "When specifying a proxy address in --auth-server, do not verify its TLS certificate. Danger: any data you send can be intercepted or modified by an attacker.").
BoolVar(&ccf.Insecure)
modes := []string{mfa.MFAModeAuto, mfa.MFAModeCrossPlatform, mfa.MFAModePlatform, mfa.MFAModeSSO, mfa.MFAModeBrowser}
app.Flag("mfa-mode", fmt.Sprintf("Preferred mode for MFA assertions (%v).", strings.Join(modes, ", "))).
Default(mfa.MFAModeAuto).
Envar(mfaModeEnvVar).
EnumVar(&ccf.MFAMode, modes...)
app.HelpFlag.Short('h')
// parse CLI commands+flags:
+3
View File
@@ -232,6 +232,9 @@ func TestConnect(t *testing.T) {
// set tsh home to a fake path so that the existence of a real
// ~/.tsh does not interfere with the test result.
cfg.TeleportHome = t.TempDir()
// set data dir to a fake path so that the existence of a real
// /var/lib/teleport does not interfere with the test result.
cfg.DataDir = t.TempDir()
if tc.modifyConfig != nil {
tc.modifyConfig(cfg)
}
+7 -1
View File
@@ -133,6 +133,8 @@ const (
mfaModeOTP = "otp"
// mfaModeSSO utilizes only SSO devices.
mfaModeSSO = "sso"
// mfaModeBrowser utilizes browser-based WebAuthn MFA via local server.
mfaModeBrowser = "browser"
)
const (
@@ -951,7 +953,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, mfaModeSSO}
modes := []string{mfaModeAuto, mfaModeCrossPlatform, mfaModePlatform, mfaModeOTP, mfaModeSSO, mfaModeBrowser}
app.Flag("mfa-mode", fmt.Sprintf("Preferred mode for MFA and Passwordless assertions (%v).", strings.Join(modes, ", "))).
Default(mfaModeAuto).
Envar(mfaModeEnvVar).
@@ -5080,6 +5082,7 @@ func loadClientConfigFromCLIConf(cf *CLIConf, proxy string) (*client.Config, err
c.AuthenticatorAttachment = mfaOpts.AuthenticatorAttachment
c.PreferOTP = mfaOpts.PreferOTP
c.PreferSSO = mfaOpts.PreferSSO
c.PreferBrowser = mfaOpts.PreferBrowser
// If agent forwarding was specified on the command line enable it.
c.ForwardAgent = options.ForwardAgent
@@ -5302,6 +5305,7 @@ type mfaModeOpts struct {
AuthenticatorAttachment wancli.AuthenticatorAttachment
PreferOTP bool
PreferSSO bool
PreferBrowser bool
}
func parseMFAMode(mode string) (*mfaModeOpts, error) {
@@ -5316,6 +5320,8 @@ func parseMFAMode(mode string) (*mfaModeOpts, error) {
opts.PreferOTP = true
case mfaModeSSO:
opts.PreferSSO = true
case mfaModeBrowser:
opts.PreferBrowser = true
default:
return nil, fmt.Errorf("invalid MFA mode: %q", mode)
}
+32 -15
View File
@@ -1508,6 +1508,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
Webauthn: &types.Webauthn{
RPID: cluster,
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
}
}
@@ -1668,7 +1669,8 @@ func TestSSHOnMultipleNodes(t *testing.T) {
Webauthn: &types.Webauthn{
RPID: "localhost",
},
RequireMFAType: types.RequireMFAType_SESSION,
RequireMFAType: types.RequireMFAType_SESSION,
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
@@ -1696,7 +1698,8 @@ func TestSSHOnMultipleNodes(t *testing.T) {
Webauthn: &types.Webauthn{
RPID: "localhost",
},
RequireMFAType: types.RequireMFAType_SESSION,
RequireMFAType: types.RequireMFAType_SESSION,
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
@@ -1721,7 +1724,8 @@ func TestSSHOnMultipleNodes(t *testing.T) {
Webauthn: &types.Webauthn{
RPID: "localhost",
},
RequireMFAType: types.RequireMFAType_SESSION,
RequireMFAType: types.RequireMFAType_SESSION,
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
@@ -1741,6 +1745,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
Webauthn: &types.Webauthn{
RPID: "localhost",
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
@@ -1813,6 +1818,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
Webauthn: &types.Webauthn{
RPID: "localhost",
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
@@ -1838,6 +1844,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
Webauthn: &types.Webauthn{
RPID: "localhost",
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
@@ -1862,6 +1869,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
Webauthn: &types.Webauthn{
RPID: "localhost",
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
@@ -2030,6 +2038,11 @@ func TestSSHOnMultipleNodes(t *testing.T) {
// so we can assert how many times sign was called.
device.SetCounter(0)
// Sleep before attempting to access the nodes to give them time to show
// up on the server, otherwise a `no target host specified` error is returned.
// 400ms was the lowest sleep that consistently fixed the test.
time.Sleep(400 * time.Millisecond)
args := []string{"ssh", "-d", "--insecure"}
if tt.headless {
args = append(args, "--headless", "--proxy", tt.proxyAddr, "--user", user.GetName())
@@ -2041,18 +2054,22 @@ func TestSSHOnMultipleNodes(t *testing.T) {
}
args = append(args, tt.target, "echo", "test", "&&", "echo", "error", ">&2")
err = Run(ctx,
args,
setHomePath(tmpHomePath),
func(conf *CLIConf) error {
conf.overrideStdin = stdin
conf.OverrideStdout = stdout
conf.overrideStderr = stderr
conf.MockHeadlessLogin = mockHeadlessLogin(t, tt.auth, user)
conf.WebauthnLogin = tt.webauthnLogin
return nil
},
)
var runOpts []CliOption
runOpts = append(runOpts, setHomePath(tmpHomePath))
// Only add SSO mock for non-headless tests
if !tt.headless {
runOpts = append(runOpts, setMockSSOLogin(tt.auth, user, connector.GetName()))
}
runOpts = append(runOpts, func(conf *CLIConf) error {
conf.overrideStdin = stdin
conf.OverrideStdout = stdout
conf.overrideStderr = stderr
conf.MockHeadlessLogin = mockHeadlessLogin(t, tt.auth, user)
conf.WebauthnLogin = tt.webauthnLogin
return nil
})
err = Run(ctx, args, runOpts...)
tt.errAssertion(t, err)
tt.stdoutAssertion(t, stdout.String())