From 5b19ccce710cd088160c2ebc2f72ecca820968ba Mon Sep 17 00:00:00 2001 From: Dan Share Date: Fri, 20 Mar 2026 08:50:43 +0000 Subject: [PATCH] [Browser MFA] Add Browser MFA to Connect --- lib/client/mfa/prompt.go | 2 +- lib/client/sso/ceremony.go | 45 +++++++++++-- lib/teleterm/clusters/cluster.go | 8 ++- lib/teleterm/clusters/cluster_auth.go | 9 +-- lib/teleterm/daemon/mfaprompt.go | 30 ++++----- .../teleterm/src/ui/ModalsHost/ModalsHost.tsx | 1 + .../ReAuthenticate/ReAuthenticate.story.tsx | 48 ++++++++++++++ .../modals/ReAuthenticate/ReAuthenticate.tsx | 63 +++++++++++++++---- .../src/ui/services/modals/modalsService.ts | 1 + web/packages/teleterm/src/ui/tshdEvents.ts | 3 + 10 files changed, 170 insertions(+), 40 deletions(-) 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,