[client] Verify the SSO login came back for the hinted account

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.
This commit is contained in:
Zoltán Papp
2026-08-18 10:51:05 +02:00
parent bfa5d0e1f3
commit 907e66b9d7
7 changed files with 436 additions and 26 deletions

View File

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