From 907e66b9d7fe89b7db2b4f9145c33958a4e41c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 18 Aug 2026 10:51:05 +0200 Subject: [PATCH] [client] Verify the SSO login came back for the hinted account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit login_hint is a suggestion the IdP may ignore: with a silent flow configured (DisablePromptLogin or max_age=0) and a live IdP session for another account, the login completes with that account's token. On a registered peer the management server rejects it as a user mismatch, but on a fresh profile the peer silently registers under the wrong account and the profile is then bound to it — every later login follows the stored hint straight back. After the token exchange, compare the ID token's email against the hint the flow was sent with. On a mismatch, do not log in to management with the token; run one more round asking the IdP to re-decide the account (prompt=login, via ForceAccountPrompt — DisablePromptLogin still wins there). If the prompted round also comes back different, proceed with a warning: the address may legitimately have changed, and refusing forever would lock the user out of the profile while the management server still rejects a token that does not own the peer. A token or profile with no email to compare is not judged. The retry differs per platform because of who opens the browser: - CLI (netbird login foreground) and Android run the whole flow in one process, so the mismatch retries automatically: the browser reopens with the account prompt within the same login attempt. - On desktop the login is split between the daemon and the GUI: Login hands the authorize URL to the GUI, WaitSSOLogin blocks for the token, and only the GUI can open a browser. A new URL cannot be handed out from inside WaitSSOLogin (its response has no field for one, kept that way to avoid a proto change), so the daemon arms forceAccountPrompt, fails the round with "connect again to choose the account", and builds the next Login's flow with the prompt — the user's next connect is the retry. The flag and the flow annotations live in daemon memory only; SwitchProfile drops them so the previous profile's hint cannot judge the next profile's token. The device code flow has no prompt parameter (RFC 8628), so a prompted round there runs as-is and a repeated mismatch is let through with the warning rather than looping. --- client/android/login.go | 43 ++++++- client/cmd/login.go | 33 ++++++ client/internal/auth/account_match_test.go | 104 +++++++++++++++++ client/internal/auth/oauth.go | 43 +++++++ client/internal/auth/pkce_flow.go | 53 +++++---- client/server/login_account_test.go | 125 +++++++++++++++++++++ client/server/server.go | 61 ++++++++++ 7 files changed, 436 insertions(+), 26 deletions(-) create mode 100644 client/internal/auth/account_match_test.go create mode 100644 client/server/login_account_test.go diff --git a/client/android/login.go b/client/android/login.go index 5f1a7880d..92883ec80 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -215,14 +215,49 @@ func (a *Auth) foregroundGetTokenInfoFlow(authClient *auth.Auth, urlOpener URLOp // An empty hint is deliberate, not a fallback: a fresh profile leaves the // choice to the IdP. Switching accounts is done by switching or removing // profiles, not by logging out — logout keeps the email. + hint := "" if a.cfgPath != "" { - if hint := readProfileEmail(a.cfgPath); hint != "" { - if setter, ok := oAuthFlow.(loginHintSetter); ok { - setter.SetLoginHint(hint) - } + hint = readProfileEmail(a.cfgPath) + } + if hint != "" { + if setter, ok := oAuthFlow.(loginHintSetter); ok { + setter.SetLoginHint(hint) } } + tokenInfo, err := a.runInteractiveFlow(oAuthFlow, urlOpener) + if err != nil { + return nil, err + } + + if tokenInfo.MatchesAccount(hint) { + return tokenInfo, nil + } + + // The IdP answered from a session belonging to another account. Retrying is + // what makes this recoverable: on a peer already registered the server would + // reject the token, and on a fresh one it would silently register the peer + // under the wrong account and bind the profile to it. + log.Infof("login returned an account other than the one this profile is bound to, retrying with an account prompt") + retryFlow := auth.RetryFlowForAccount(oAuthFlow) + if retryFlow == nil { + return tokenInfo, nil + } + + retryToken, err := a.runInteractiveFlow(retryFlow, urlOpener) + if err != nil { + return nil, err + } + if !retryToken.MatchesAccount(hint) { + log.Warnf("login still returned a different account after the prompt, continuing with it") + } + + return retryToken, nil +} + +// runInteractiveFlow requests the authorization info, hands the URL to the +// user and blocks until the token comes back. +func (a *Auth) runInteractiveFlow(oAuthFlow auth.OAuthFlow, urlOpener URLOpener) (*auth.TokenInfo, error) { flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO()) if err != nil { return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) diff --git a/client/cmd/login.go b/client/cmd/login.go index 53bc19149..f613b956e 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -413,6 +413,39 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro return nil, err } + tokenInfo, err := runInteractiveFlow(cmd, oAuthFlow) + if err != nil { + return nil, err + } + + if tokenInfo.MatchesAccount(hint) { + return tokenInfo, nil + } + + // The IdP answered from a session belonging to another account. Retrying is + // what makes this recoverable: on a peer already registered the server would + // reject the token, and on a fresh one it would silently register the peer + // under the wrong account and bind the profile to it. + cmd.Println("The login returned a different account than this profile uses. Asking to sign in again.") + retryFlow := auth.RetryFlowForAccount(oAuthFlow) + if retryFlow == nil { + return tokenInfo, nil + } + + retryToken, err := runInteractiveFlow(cmd, retryFlow) + if err != nil { + return nil, err + } + if !retryToken.MatchesAccount(hint) { + log.Warnf("login still returned a different account after the prompt, continuing with it") + } + + return retryToken, nil +} + +// runInteractiveFlow requests the authorization info, shows the URL to the user +// and blocks until the token comes back. +func runInteractiveFlow(cmd *cobra.Command, oAuthFlow auth.OAuthFlow) (*auth.TokenInfo, error) { flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO()) if err != nil { return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) diff --git a/client/internal/auth/account_match_test.go b/client/internal/auth/account_match_test.go new file mode 100644 index 000000000..c95a868f8 --- /dev/null +++ b/client/internal/auth/account_match_test.go @@ -0,0 +1,104 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenInfoMatchesAccount(t *testing.T) { + tests := []struct { + name string + token TokenInfo + hint string + match bool + }{ + { + name: "same account", + token: TokenInfo{Email: "user@example.com"}, + hint: "user@example.com", + match: true, + }, + { + name: "different account", + token: TokenInfo{Email: "other@example.com"}, + hint: "user@example.com", + match: false, + }, + { + name: "case differences are the same account", + token: TokenInfo{Email: "User@Example.com"}, + hint: "user@example.com", + match: true, + }, + { + name: "no hint leaves the choice to the IdP", + token: TokenInfo{Email: "other@example.com"}, + hint: "", + match: true, + }, + { + name: "token without an email is not judged", + token: TokenInfo{Email: ""}, + hint: "user@example.com", + match: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.match, tc.token.MatchesAccount(tc.hint)) + }) + } +} + +func TestParseEmailFromIDToken(t *testing.T) { + tests := []struct { + name string + claims map[string]interface{} + wantValue string + wantErr bool + }{ + { + name: "email claim", + claims: map[string]interface{}{"email": "user@example.com", "name": "Some One"}, + wantValue: "user@example.com", + }, + { + name: "name fallback", + claims: map[string]interface{}{"name": "Some One"}, + wantValue: "Some One", + }, + { + name: "neither claim present", + claims: map[string]interface{}{"sub": "abc"}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + value, err := parseEmailFromIDToken(idTokenWithClaims(t, tc.claims)) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantValue, value) + }) + } +} + +func TestRetryFlowForAccountUnsupportedFlow(t *testing.T) { + assert.Nil(t, RetryFlowForAccount(&DeviceAuthorizationFlow{})) +} + +func idTokenWithClaims(t *testing.T, claims map[string]interface{}) string { + t.Helper() + payload, err := json.Marshal(claims) + require.NoError(t, err) + return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" +} diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go index f0c2f72ea..b55e75647 100644 --- a/client/internal/auth/oauth.go +++ b/client/internal/auth/oauth.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "runtime" + "strings" log "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" @@ -25,6 +26,14 @@ type HTTPClient interface { Do(req *http.Request) (*http.Response, error) } +// accountPromptForcer is implemented by the PKCE flow only. The device code +// flow has no equivalent: RFC 8628 defines no prompt parameter, and the user +// confirms the code on a page that shows which account signs in, so a silent +// wrong-account answer is not the failure mode there. +type accountPromptForcer interface { + ForceAccountPrompt() +} + // AuthFlowInfo holds information for the OAuth 2.0 authorization flow type AuthFlowInfo struct { //nolint:revive DeviceCode string `json:"device_code"` @@ -51,6 +60,22 @@ type TokenInfo struct { Email string `json:"-"` } +// MatchesAccount reports whether the token belongs to the account a profile is +// bound to. A hint the IdP could not have acted on — no hint stored, or a token +// that carried no email — is reported as a match: the check exists to catch a +// login answered from the wrong account, not to block one it cannot judge. +// +// The comparison is case-insensitive. Local-parts are case-sensitive per RFC +// 5321, but no IdP in practice issues two accounts differing only in case, and +// an IdP that echoes a differently-cased address would otherwise fail every +// login. +func (t TokenInfo) MatchesAccount(hint string) bool { + if hint == "" || t.Email == "" { + return true + } + return strings.EqualFold(t.Email, hint) +} + // GetTokenToUse returns either the access or id token based on UseIDToken field func (t TokenInfo) GetTokenToUse() string { if t.UseIDToken { @@ -136,3 +161,21 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager. return deviceFlowInfo, nil } + +// RetryFlowForAccount returns a flow that asks the IdP to re-authenticate, for +// a login answered with an account other than the one hinted. Returns nil when +// the flow cannot ask — the caller then proceeds with the token it has. +// +// Proceeding rather than failing is deliberate. The hint is an email that may +// simply have changed since it was stored, and refusing the login would lock a +// user out of their own profile over a rename. The retry gives the account a +// chance to be corrected; the server still rejects a token that does not own +// the peer. +func RetryFlowForAccount(flow OAuthFlow) OAuthFlow { + forcer, ok := flow.(accountPromptForcer) + if !ok { + return nil + } + forcer.ForceAccountPrompt() + return flow +} diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index be64cc6a8..9e08cc0ed 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -87,10 +87,11 @@ func validatePKCEConfig(config *PKCEAuthProviderConfig) error { // PKCEAuthorizationFlow implements the OAuthFlow interface for // the Authorization Code Flow with PKCE. type PKCEAuthorizationFlow struct { - providerConfig PKCEAuthProviderConfig - state string - codeVerifier string - oAuthConfig *oauth2.Config + providerConfig PKCEAuthProviderConfig + state string + codeVerifier string + oAuthConfig *oauth2.Config + forceAccountPrompt bool } // NewPKCEAuthorizationFlow returns new PKCE authorization code flow. @@ -154,10 +155,12 @@ func (p *PKCEAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowIn oauth2.SetAuthURLParam("audience", p.providerConfig.Audience), } if !p.providerConfig.DisablePromptLogin { - switch p.providerConfig.LoginFlag { - case common.LoginFlagPromptLogin: + switch { + case p.forceAccountPrompt: params = append(params, oauth2.SetAuthURLParam("prompt", "login")) - case common.LoginFlagMaxAge0: + case p.providerConfig.LoginFlag == common.LoginFlagPromptLogin: + params = append(params, oauth2.SetAuthURLParam("prompt", "login")) + case p.providerConfig.LoginFlag == common.LoginFlagMaxAge0: params = append(params, oauth2.SetAuthURLParam("max_age", "0")) } } @@ -178,6 +181,17 @@ func (p *PKCEAuthorizationFlow) SetLoginHint(hint string) { p.providerConfig.LoginHint = hint } +// ForceAccountPrompt makes the next authorization request ask the IdP to +// re-authenticate instead of answering from the session it already holds. Used +// to retry a login that came back for an account other than the one hinted. +// +// DisablePromptLogin still wins: it is set for IdPs that break on prompt=login, +// where retrying with it would replace a wrong-account login with one that +// cannot complete at all. +func (p *PKCEAuthorizationFlow) ForceAccountPrompt() { + p.forceAccountPrompt = true +} + // WaitToken waits for the OAuth token in the PKCE Authorization Flow. // It starts an HTTP server to receive the OAuth token callback and waits for the token or an error. // Once the token is received, it is converted to TokenInfo and validated before returning. @@ -321,10 +335,10 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, } // parseEmailFromIDToken extracts the email (or name) claim from an ID token -// without verifying its signature. The value is best-effort and used only as a -// UX convenience (login hint prefill and display); it never drives an -// authorization decision. The authoritative identity is established server-side -// from the signature-verified token. +// without verifying its signature. The value is best-effort: it prefills the +// login hint, is displayed, and is compared against the account a profile is +// bound to (see MatchesAccount). It never grants anything — the authoritative +// identity is established server-side from the signature-verified token. func parseEmailFromIDToken(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) < 2 { @@ -340,19 +354,14 @@ func parseEmailFromIDToken(token string) (string, error) { return "", fmt.Errorf("json unmarshal error: %w", err) } - var email string - if emailValue, ok := claims["email"].(string); ok { - email = emailValue - } else { - val, ok := claims["name"].(string) - if ok { - email = val - } else { - return "", fmt.Errorf("email or name field not found in token payload") - } + if email, ok := claims["email"].(string); ok { + return email, nil + } + if name, ok := claims["name"].(string); ok { + return name, nil } - return email, nil + return "", fmt.Errorf("email or name field not found in token payload") } func createCodeChallenge(codeVerifier string) string { diff --git a/client/server/login_account_test.go b/client/server/login_account_test.go new file mode 100644 index 000000000..8d88c75f0 --- /dev/null +++ b/client/server/login_account_test.go @@ -0,0 +1,125 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/proto" +) + +type stubOAuthFlow struct { + token auth.TokenInfo +} + +func (f *stubOAuthFlow) RequestAuthInfo(context.Context) (auth.AuthFlowInfo, error) { + return auth.AuthFlowInfo{}, nil +} + +func (f *stubOAuthFlow) WaitToken(context.Context, auth.AuthFlowInfo) (auth.TokenInfo, error) { + return f.token, nil +} + +func (f *stubOAuthFlow) GetClientID(context.Context) string { + return "stub-client" +} + +func TestWaitSSOLogin_WrongAccountArmsPromptAndFails(t *testing.T) { + s := newSSOTestServer(t, "user@example.com", false, "other@example.com") + attempts := 0 + s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) { + attempts++ + return "", nil + } + + resp, err := s.WaitSSOLogin(context.Background(), &proto.WaitSSOLoginRequest{UserCode: "code"}) + require.Error(t, err) + require.Nil(t, resp) + require.Equal(t, 0, attempts, "the wrong account's token reached the management login") + require.True(t, s.forceAccountPrompt, "the next login was not armed to ask for the account") + require.Nil(t, s.oauthAuthFlow.flow, "the mismatched flow stayed cached for reuse") + + status, stateErr := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, stateErr) + require.Equal(t, internal.StatusNeedsLogin, status, "the mismatch must stay retryable") +} + +func TestWaitSSOLogin_WrongAccountAfterPromptProceeds(t *testing.T) { + s := newSSOTestServer(t, "user@example.com", true, "other@example.com") + attempts := 0 + s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) { + attempts++ + return "", nil + } + + resp, err := s.WaitSSOLogin(context.Background(), &proto.WaitSSOLoginRequest{UserCode: "code"}) + require.NoError(t, err, "a prompted round must not error again on a mismatch") + require.NotNil(t, resp) + require.Equal(t, "other@example.com", resp.Email) + require.Equal(t, 1, attempts) + require.False(t, s.forceAccountPrompt) +} + +func TestWaitSSOLogin_MatchingAccountProceeds(t *testing.T) { + s := newSSOTestServer(t, "user@example.com", false, "User@Example.com") + attempts := 0 + s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) { + attempts++ + return "", nil + } + + resp, err := s.WaitSSOLogin(context.Background(), &proto.WaitSSOLoginRequest{UserCode: "code"}) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 1, attempts) + require.False(t, s.forceAccountPrompt) +} + +func TestWaitSSOLogin_NoHintIsNotJudged(t *testing.T) { + s := newSSOTestServer(t, "", false, "whoever@example.com") + attempts := 0 + s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) { + attempts++ + return "", nil + } + + _, err := s.WaitSSOLogin(context.Background(), &proto.WaitSSOLoginRequest{UserCode: "code"}) + require.NoError(t, err) + require.Equal(t, 1, attempts) + require.False(t, s.forceAccountPrompt) +} + +func TestSwitchProfile_DropsAccountPromptAndPendingFlow(t *testing.T) { + s, ctx, _, _, _ := setupServerWithProfile(t) + s.forceAccountPrompt = true + cancelled := false + s.oauthAuthFlow = oauthAuthFlow{ + flow: &stubOAuthFlow{}, + hint: "user@example.com", + waitCancel: func() { cancelled = true }, + } + + _, err := s.SwitchProfile(ctx, nil) + require.NoError(t, err) + require.False(t, s.forceAccountPrompt, "the prompt flag leaked across a profile switch") + require.Nil(t, s.oauthAuthFlow.flow, "the previous profile's flow leaked across a profile switch") + require.Empty(t, s.oauthAuthFlow.hint) + require.True(t, cancelled, "the pending wait was not cancelled") +} + +func newSSOTestServer(t *testing.T, hint string, accountPrompted bool, tokenEmail string) *Server { + t.Helper() + s := New(internal.CtxInitState(context.Background()), "console", "", false, false, false, false) + s.oauthAuthFlow = oauthAuthFlow{ + flow: &stubOAuthFlow{token: auth.TokenInfo{Email: tokenEmail}}, + info: auth.AuthFlowInfo{UserCode: "code"}, + expiresAt: time.Now().Add(time.Minute), + hint: hint, + accountPrompted: accountPrompted, + } + return s +} diff --git a/client/server/server.go b/client/server/server.go index 25cc064b9..eff76fa20 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -82,6 +82,12 @@ type Server struct { uiLogPath string oauthAuthFlow oauthAuthFlow + // forceAccountPrompt makes the next startSSOLogin build its flow with a + // forced account prompt. Armed when a login came back for an account other + // than the hinted one: that flow's browser is gone, so the correction has to + // ride on the user's next connect. Guarded by mutex; deliberately not + // persisted — a lost flag only costs one more mismatch round. + forceAccountPrompt bool // extendAuthSessionFlow holds the pending PKCE flow created by // RequestExtendAuthSession until WaitExtendAuthSession resolves it. // Kept separate from oauthAuthFlow (which is reserved for the SSH @@ -153,6 +159,14 @@ type oauthAuthFlow struct { flow auth.OAuthFlow info auth.AuthFlowInfo waitCancel context.CancelFunc + // hint is the account the flow was asked to sign in (login_hint). The token + // that comes back is compared against it; empty means nothing to compare. + hint string + // accountPrompted records that this flow already asked the IdP to re-decide + // the account (or could not ask — the device flow has no way to). A token + // for the wrong account is then let through with a warning instead of + // erroring again, so the flow cannot loop. + accountPrompted bool } // New server instance constructor. @@ -709,6 +723,16 @@ func (s *Server) startSSOLogin(ctx context.Context, msg *proto.LoginRequest, con return nil, err } + s.mutex.Lock() + promptForAccount := s.forceAccountPrompt + s.forceAccountPrompt = false + s.mutex.Unlock() + if promptForAccount && auth.RetryFlowForAccount(oAuthFlow) == nil { + // The device flow cannot ask; run it as-is. accountPrompted still goes + // true below so a second mismatch is let through instead of looping. + log.Warnf("the previous login returned a different account, but this flow cannot ask the IdP to choose one") + } + if resp := s.reuseOAuthFlow(ctx, oAuthFlow, state); resp != nil { return resp, nil } @@ -723,6 +747,8 @@ func (s *Server) startSSOLogin(ctx context.Context, msg *proto.LoginRequest, con s.oauthAuthFlow.flow = oAuthFlow s.oauthAuthFlow.info = authInfo s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second) + s.oauthAuthFlow.hint = hint + s.oauthAuthFlow.accountPrompted = promptForAccount s.mutex.Unlock() state.Set(internal.StatusNeedsLogin) @@ -923,8 +949,33 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.mutex.Lock() s.oauthAuthFlow.expiresAt = time.Now() + hint := s.oauthAuthFlow.hint + accountPrompted := s.oauthAuthFlow.accountPrompted s.mutex.Unlock() + if !tokenInfo.MatchesAccount(hint) { + if !accountPrompted { + // The IdP answered from a session belonging to another account. The + // browser for this flow is gone, so a new URL cannot be handed out + // here — arm the prompt for the user's next connect and fail this + // round. Never log in with the token: on a registered peer the + // server would reject it, and on a fresh one it would silently + // register the peer under the wrong account. + log.Warnf("login returned an account other than the one this profile is bound to; the next connect will ask the IdP to choose") + s.mutex.Lock() + s.oauthAuthFlow = oauthAuthFlow{} + s.forceAccountPrompt = true + s.mutex.Unlock() + state.Set(internal.StatusNeedsLogin) + return nil, gstatus.Errorf(codes.FailedPrecondition, "the login used a different account than this profile; connect again to choose the account") + } + // Already asked once; the account may legitimately differ (a changed + // email address). Refusing again would lock the user out of the profile, + // and the management server still rejects a token that does not own the + // peer. + log.Warnf("login still returned a different account after the prompt, continuing with it") + } + if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil { state.Set(loginStatus) return nil, err @@ -1225,6 +1276,16 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi s.config = config + // A pending login flow and the account-prompt flag describe the previous + // profile's login; carried across a switch they would judge the new + // profile's token against the old profile's account. CancelFunc is + // non-blocking, so calling it under the mutex is safe. + if cancel := s.oauthAuthFlow.waitCancel; cancel != nil { + cancel() + } + s.oauthAuthFlow = oauthAuthFlow{} + s.forceAccountPrompt = false + if msg != nil && msg.ProfileName != nil { s.publishProfileListChanged(*msg.ProfileName) }