[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
+43
View File
@@ -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
}