[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

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

View File

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

View File

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

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
}

View File

@@ -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 {

View File

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

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