diff --git a/lib/client/mfa/prompt.go b/lib/client/mfa/prompt.go
index 8adb56a3b262d..3ce393ed53f60 100644
--- a/lib/client/mfa/prompt.go
+++ b/lib/client/mfa/prompt.go
@@ -88,7 +88,7 @@ type MFAGoroutineResponse struct {
// HandleMFAPromptGoroutines spawns MFA prompt goroutines and returns the first successful response,
// terminating error, or an aggregated error if they all fail.
func HandleMFAPromptGoroutines(ctx context.Context, startGoroutines func(context.Context, *sync.WaitGroup, chan<- MFAGoroutineResponse)) (*proto.MFAAuthenticateResponse, error) {
- respC := make(chan MFAGoroutineResponse, 2)
+ respC := make(chan MFAGoroutineResponse, 3)
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(ctx)
diff --git a/lib/client/sso/ceremony.go b/lib/client/sso/ceremony.go
index f405069f7fe65..8d9ae4fa68e86 100644
--- a/lib/client/sso/ceremony.go
+++ b/lib/client/sso/ceremony.go
@@ -110,6 +110,9 @@ type MFACeremony struct {
HandleRedirect func(ctx context.Context, redirectURL string) error
GetCallbackMFAToken func(ctx context.Context) (string, error)
GetCallbackWebauthn func(ctx context.Context) (*wantypes.CredentialAssertionResponse, error)
+ // GetCallbackResponse returns the response from the server without assuming
+ // if it is SSO or Browser MFA, so it can be inspected and actioned.
+ GetCallbackResponse func(ctx context.Context) (*authclient.CLILoginResponse, error)
}
// GetClientCallbackURL returns the client callback URL.
@@ -124,9 +127,38 @@ func (m *MFACeremony) GetProxyAddress() string {
// Run the SSO/Browser MFA ceremony.
func (m *MFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
- // The proxy will only ever return one of SSO or Browser challenge. However,
- // check for SSO challenge first as it takes priority over browser MFA.
switch {
+ // If both SSO and Browser MFA challenges are set then Connect has initiated
+ // this MFA ceremony. In which case, don't print the redirect URL and listen
+ // for either response to be returned.
+ case chal.SSOChallenge != nil && chal.BrowserMFAChallenge != nil:
+ loginResp, err := m.GetCallbackResponse(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ switch {
+ case loginResp.MFAToken != "":
+ return &proto.MFAAuthenticateResponse{
+ Response: &proto.MFAAuthenticateResponse_SSO{
+ SSO: &proto.SSOResponse{
+ RequestId: chal.SSOChallenge.RequestId,
+ Token: loginResp.MFAToken,
+ },
+ },
+ }, nil
+ case loginResp.BrowserMFAWebauthnResponse != nil:
+ return &proto.MFAAuthenticateResponse{
+ Response: &proto.MFAAuthenticateResponse_Browser{
+ Browser: &proto.BrowserMFAResponse{
+ RequestId: chal.BrowserMFAChallenge.RequestId,
+ WebauthnResponse: wantypes.CredentialAssertionResponseToProto(loginResp.BrowserMFAWebauthnResponse),
+ },
+ },
+ }, nil
+ default:
+ return nil, trace.BadParameter("login response missing both SSO MFA token and Browser WebAuthn response")
+ }
case chal.SSOChallenge != nil:
if err := m.HandleRedirect(ctx, chal.SSOChallenge.RedirectUrl); err != nil {
return nil, trace.Wrap(err)
@@ -220,12 +252,13 @@ func NewCLIMFACeremony(rd *Redirector) *MFACeremony {
}
}
-// NewConnectMFACeremony creates a new Teleport Connect SSO ceremony from the given redirector.
+// NewConnectMFACeremony creates a new Teleport Connect SSO/Browser ceremony from the given redirector.
func NewConnectMFACeremony(rd *Redirector) mfa.CallbackCeremony {
return &MFACeremony{
- close: rd.Close,
- ClientCallbackURL: rd.ClientCallbackURL,
- ProxyAddress: rd.ProxyAddr,
+ close: rd.Close,
+ ClientCallbackURL: rd.ClientCallbackURL,
+ ProxyAddress: rd.ProxyAddr,
+ GetCallbackResponse: rd.WaitForResponse,
HandleRedirect: func(ctx context.Context, redirectURL string) error {
// Connect handles redirect on the Electron side.
return nil
diff --git a/lib/teleterm/clusters/cluster.go b/lib/teleterm/clusters/cluster.go
index 06b8ee6f75424..dee36e6963e0e 100644
--- a/lib/teleterm/clusters/cluster.go
+++ b/lib/teleterm/clusters/cluster.go
@@ -328,9 +328,13 @@ func (c *Cluster) GetProfileStatusError() error {
}
// GetProxyHost returns proxy address (hostname:port) of the root cluster, even when called on a
-// Cluster that represents a leaf cluster.
+// Cluster that represents a leaf cluster. Falls back to WebProxyAddr when the profile status has
+// not been loaded yet (e.g., before the first login).
func (c *Cluster) GetProxyHost() string {
- return c.status.ProxyURL.Host
+ if c.status.ProxyURL.Host != "" {
+ return c.status.ProxyURL.Host
+ }
+ return c.WebProxyAddr
}
// HasDeviceTrustExtensions indicates if the cert contains all required
diff --git a/lib/teleterm/clusters/cluster_auth.go b/lib/teleterm/clusters/cluster_auth.go
index 0b3473f7bb99c..fcbba714b0023 100644
--- a/lib/teleterm/clusters/cluster_auth.go
+++ b/lib/teleterm/clusters/cluster_auth.go
@@ -194,10 +194,11 @@ func (c *Cluster) localMFALogin(user, password string) client.SSHLoginFunc {
}
response, err := client.SSHAgentMFALogin(ctx, client.SSHLoginMFA{
- SSHLogin: sshLogin,
- User: user,
- Password: password,
- MFAPromptConstructor: c.clusterClient.NewMFAPrompt,
+ SSHLogin: sshLogin,
+ User: user,
+ Password: password,
+ MFAPromptConstructor: c.clusterClient.NewMFAPrompt,
+ MFACeremonyConstructor: c.clusterClient.NewRedirectorMFACeremony,
})
if err != nil {
return nil, trace.Wrap(err)
diff --git a/lib/teleterm/daemon/mfaprompt.go b/lib/teleterm/daemon/mfaprompt.go
index 5c12c6050279d..c531e45d4c067 100644
--- a/lib/teleterm/daemon/mfaprompt.go
+++ b/lib/teleterm/daemon/mfaprompt.go
@@ -75,18 +75,10 @@ func (p *mfaPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng
promptOTP := chal.TOTP != nil
promptWebauthn := chal.WebauthnChallenge != nil && p.cfg.WebauthnSupported
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",
- )
- }
-
+ promptBrowserMfa := chal.BrowserMFAChallenge != nil && p.cfg.MFACeremony != nil
scope := p.cfg.Extensions.GetScope()
// No prompt to run, no-op.
- if !promptOTP && !promptWebauthn && !promptSSO {
+ if !promptOTP && !promptWebauthn && !promptSSO && !promptBrowserMfa {
return &proto.MFAAuthenticateResponse{}, nil
}
@@ -100,6 +92,13 @@ func (p *mfaPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng
}
}
+ var browserMfaChallenge *mfav1.BrowserMFAChallenge
+ if promptBrowserMfa {
+ browserMfaChallenge = &mfav1.BrowserMFAChallenge{
+ RequestId: chal.BrowserMFAChallenge.RequestId,
+ }
+ }
+
spawnGoroutines := func(ctx context.Context, wg *sync.WaitGroup, respC chan<- libmfa.MFAGoroutineResponse) {
ctx, cancel := context.WithCancelCause(ctx)
@@ -114,6 +113,7 @@ func (p *mfaPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng
Totp: promptOTP,
Webauthn: promptWebauthn,
Sso: ssoChallenge,
+ Browser: browserMfaChallenge,
PerSessionMfa: scope == mfav1.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION,
})
respC <- libmfa.MFAGoroutineResponse{Resp: resp, Err: err}
@@ -136,14 +136,14 @@ func (p *mfaPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng
}()
}
- // Fire SSO goroutine.
- if promptSSO {
+ // Fire SSO/Browser MFA goroutine. They both share the same callback handler.
+ if promptSSO || promptBrowserMfa {
wg.Add(1)
go func() {
defer wg.Done()
- resp, err := p.promptSSO(ctx, chal)
- respC <- libmfa.MFAGoroutineResponse{Resp: resp, Err: trace.Wrap(err, "SSO authentication failed")}
+ resp, err := p.promptBrowserOrSSO(ctx, chal)
+ respC <- libmfa.MFAGoroutineResponse{Resp: resp, Err: trace.Wrap(err, "SSO/Browser MFA authentication failed")}
}()
}
}
@@ -174,7 +174,7 @@ func (p *mfaPrompt) promptWebauthn(ctx context.Context, chal *proto.MFAAuthentic
return resp, nil
}
-func (c *mfaPrompt) promptSSO(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
+func (c *mfaPrompt) promptBrowserOrSSO(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
resp, err := c.cfg.MFACeremony.Run(ctx, chal)
return resp, trace.Wrap(err)
}
diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
index 005724559aa0a..1eb3f75af3c94 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
+++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
@@ -206,6 +206,7 @@ function renderDialog({
}}
// This function needs to be stable between renders.
onSsoContinue={dialog.onSsoContinue}
+ onBrowserMfaContinue={dialog.onBrowserMfaContinue}
onCancel={() => {
handleClose();
dialog.onCancel();
diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.story.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.story.tsx
index 5bf9a80e31a7c..bf8c9149f19b9 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.story.tsx
+++ b/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.story.tsx
@@ -55,6 +55,7 @@ export const WithWebauthn = () => (
'You somehow submitted a form while only Webauthn was available.'
);
}}
+ onBrowserMfaContinue={() => {}}
/>
);
@@ -69,6 +70,7 @@ export const WithTotp = () => (
onSsoContinue={() => {}}
onCancel={() => {}}
onOtpSubmit={showToken}
+ onBrowserMfaContinue={() => {}}
/>
);
@@ -83,6 +85,7 @@ export const WithTotpPerSessionMfa = () => (
onSsoContinue={() => {}}
onCancel={() => {}}
onOtpSubmit={showToken}
+ onBrowserMfaContinue={() => {}}
/>
);
@@ -106,6 +109,28 @@ export const WithSso = () => (
'You somehow submitted a form while only SSO was available.'
);
}}
+ onBrowserMfaContinue={() => {}}
+ />
+
+);
+
+export const WithBrowserMfa = () => (
+
+ {}}
+ onCancel={() => {}}
+ onOtpSubmit={() => {
+ window.alert(
+ 'You somehow submitted a form while only Browser MFA was available.'
+ );
+ }}
+ onBrowserMfaContinue={() => {}}
/>
);
@@ -127,6 +152,7 @@ export const WithWebauthnAndTotpAndSSO = () => (
onSsoContinue={() => {}}
onCancel={() => {}}
onOtpSubmit={showToken}
+ onBrowserMfaContinue={() => {}}
/>
);
@@ -148,6 +174,26 @@ export const WithWebauthnAndTotpAndSSOPerSessionMfa = () => (
onSsoContinue={() => {}}
onCancel={() => {}}
onOtpSubmit={showToken}
+ onBrowserMfaContinue={() => {}}
+ />
+
+);
+
+export const WithWebauthnAndTotpAndBrowserMfa = () => (
+
+ {}}
+ onCancel={() => {}}
+ onOtpSubmit={showToken}
+ onBrowserMfaContinue={() => {}}
/>
);
@@ -165,6 +211,7 @@ export const MultilineTitle = () => (
onSsoContinue={() => {}}
onCancel={() => {}}
onOtpSubmit={showToken}
+ onBrowserMfaContinue={() => {}}
/>
);
@@ -181,6 +228,7 @@ export const ForLeafCluster = () => (
onSsoContinue={() => {}}
onCancel={() => {}}
onOtpSubmit={showToken}
+ onBrowserMfaContinue={() => {}}
/>
);
diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.tsx
index e976ad25187d8..9b39a536bf091 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.tsx
+++ b/web/packages/teleterm/src/ui/ModalsHost/modals/ReAuthenticate/ReAuthenticate.tsx
@@ -54,9 +54,10 @@ export const ReAuthenticate: FC<{
onCancel: () => void;
onOtpSubmit: (otp: string) => void;
onSsoContinue: (redirectUrl: string) => void;
+ onBrowserMfaContinue: (redirectUrl: string) => void;
hidden?: boolean;
}> = props => {
- const { promptMfaRequest: req, onSsoContinue } = props;
+ const { promptMfaRequest: req, onSsoContinue, onBrowserMfaContinue } = props;
const availableMfaTypes = makeAvailableMfaTypes(req);
@@ -64,15 +65,6 @@ export const ReAuthenticate: FC<{
availableMfaTypes[0]
);
- useEffect(() => {
- // If SSO is the selected value, open the redirect window instead of waiting for the user to
- // select SSO. This handles both a situation where the user selects the SSO option and a
- // situation where SSO is already selected when the component renders.
- if (selectedMfaType.value === 'sso') {
- onSsoContinue(req.sso.redirectUrl);
- }
- }, [selectedMfaType.value, req.sso?.redirectUrl, onSsoContinue]);
-
const [otpToken, setOtpToken] = useState('');
const { clusterUri } = req;
@@ -90,6 +82,39 @@ export const ReAuthenticate: FC<{
const clusterName = routing.parseClusterName(clusterUri);
const isLeafCluster = routing.isLeafCluster(clusterUri);
+ // maybeOpenBrowser opens the browser if the given mfaType is SSO of Browser MFA
+ function maybeOpenBrowser(mfaType: AvailableMfaType) {
+ switch (mfaType.value) {
+ case 'sso':
+ onSsoContinue(req.sso?.redirectUrl);
+ break;
+ case 'browsermfa':
+ onBrowserMfaContinue(
+ `https://${rootClusterProxyHost}/web/mfa/browser/${req.browser.requestId}`
+ );
+ break;
+ }
+ }
+
+ // selectMfaType is a wrapper for setSelectedMfaType that will also open the
+ // browser if the user has selected SSO or Browser MFA
+ function selectMfaType(mfaType: AvailableMfaType) {
+ setSelectedMfaType(mfaType);
+
+ // Open the browser if the user selected SSO or Browser MFA
+ maybeOpenBrowser(mfaType);
+ }
+
+ // If there is only one available MFA type, the user may not get a dropdown
+ // to select the MFA type. If the only available type is SSO or Browser MFA,
+ // open the browser now.
+ useEffect(() => {
+ if (availableMfaTypes.length === 1) {
+ selectMfaType(availableMfaTypes[0]);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
let $totpPrompt = (
{
- setSelectedMfaType(mfaType);
+ selectMfaType(mfaType);
}}
/>
)}
@@ -207,6 +232,15 @@ export const ReAuthenticate: FC<{
{selectedMfaType.value === 'sso' && (
)}
+
+ {selectedMfaType.value === 'browsermfa' && (
+
+
+ Please follow the steps in the browser to authenticate.
+
+
+
+ )}
@@ -233,11 +267,12 @@ export const ReAuthenticate: FC<{
);
};
-type MfaType = 'webauthn' | 'totp' | 'sso';
+type MfaType = 'webauthn' | 'totp' | 'sso' | 'browsermfa';
type AvailableMfaType = Option;
const totp = { value: 'totp' as MfaType, label: 'Authenticator App' };
const webauthn = { value: 'webauthn' as MfaType, label: 'Hardware Key' };
+const browsermfa = { value: 'browsermfa' as MfaType, label: 'Browser MFA' };
function makeAvailableMfaTypes(req: PromptMFARequest): AvailableMfaType[] {
let availableMfaTypes: AvailableMfaType[] = [];
@@ -257,6 +292,10 @@ function makeAvailableMfaTypes(req: PromptMFARequest): AvailableMfaType[] {
});
}
+ if (req.browser) {
+ availableMfaTypes.push(browsermfa);
+ }
+
// This shouldn't happen but is technically allowed by the req data structure.
if (availableMfaTypes.length === 0) {
availableMfaTypes.push(webauthn);
diff --git a/web/packages/teleterm/src/ui/services/modals/modalsService.ts b/web/packages/teleterm/src/ui/services/modals/modalsService.ts
index 9ec419b86109e..e6bc6cb20bb58 100644
--- a/web/packages/teleterm/src/ui/services/modals/modalsService.ts
+++ b/web/packages/teleterm/src/ui/services/modals/modalsService.ts
@@ -261,6 +261,7 @@ export interface DialogReAuthenticate {
promptMfaRequest: tshdEventsApi.PromptMFARequest;
onSuccess(totpCode: string): void;
onSsoContinue(redirectUrl: string): void;
+ onBrowserMfaContinue(redirectUrl: string): void;
onCancel(): void;
}
diff --git a/web/packages/teleterm/src/ui/tshdEvents.ts b/web/packages/teleterm/src/ui/tshdEvents.ts
index 8ce8805b4c2a6..110832d6f0260 100644
--- a/web/packages/teleterm/src/ui/tshdEvents.ts
+++ b/web/packages/teleterm/src/ui/tshdEvents.ts
@@ -61,6 +61,9 @@ export function createTshdEventsContextBridgeService(
onSsoContinue: (redirectUrl: string) => {
window.open(redirectUrl);
},
+ onBrowserMfaContinue: (redirectUrl: string) => {
+ window.open(redirectUrl);
+ },
onCancel: () =>
resolve({
hasCanceledModal: true,