mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 19:55:09 -04:00
[client] Don't ask for an SSO login when the login never reached management (#6983)
This commit is contained in:
89
client/server/login_outcome_test.go
Normal file
89
client/server/login_outcome_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A login that never reached Management is not a decision about the peer's
|
||||||
|
// credentials, so it must come back as a retryable error rather than an SSO
|
||||||
|
// prompt: the user cannot finish a browser login while Management is down, and
|
||||||
|
// the CLI's own backoff resolves the outage on its own once the daemon reports
|
||||||
|
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
|
||||||
|
// because Management happened to be restarting when the daemon dialed it.
|
||||||
|
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
|
||||||
|
s, _, _, username, _ := setupServerWithProfile(t)
|
||||||
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
|
|
||||||
|
unreachable := errors.New("create connection: dial context: context deadline exceeded")
|
||||||
|
attempts := 0
|
||||||
|
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
||||||
|
attempts++
|
||||||
|
return internal.StatusLoginFailed, unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
|
||||||
|
require.Nil(t, resp, "a failed login must not answer with a login response")
|
||||||
|
require.Equal(t, 1, attempts)
|
||||||
|
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
|
||||||
|
|
||||||
|
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, internal.StatusLoginFailed, status,
|
||||||
|
"a peer that could not reach Management is not waiting on a login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The counterpart: Management refusing the peer's credentials is a decision, and
|
||||||
|
// the SSO flow still has to start for it. The profile carries an unusable
|
||||||
|
// private key so the flow setup fails immediately instead of dialing, which is
|
||||||
|
// enough to show the branch was entered — the refusal itself is never what comes
|
||||||
|
// back out.
|
||||||
|
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
|
||||||
|
s, _, _, username, cfgPath := setupServerWithProfile(t)
|
||||||
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
|
breakProfilePrivateKey(t, cfgPath)
|
||||||
|
|
||||||
|
refused := gstatus.Error(codes.PermissionDenied, "peer is not registered")
|
||||||
|
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
||||||
|
return internal.StatusNeedsLogin, refused
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotErrorIs(t, err, refused,
|
||||||
|
"the refusal was handed back to the caller instead of starting the SSO flow")
|
||||||
|
|
||||||
|
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
|
||||||
|
require.NoError(t, stateErr)
|
||||||
|
require.Equal(t, internal.StatusLoginFailed, status,
|
||||||
|
"the SSO flow setup was never reached with the broken key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// breakProfilePrivateKey replaces the profile's private key with an unparseable
|
||||||
|
// one, which makes any attempt to build a Management client fail on the spot.
|
||||||
|
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(cfgPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||||
|
cfg["PrivateKey"] = "not-a-key"
|
||||||
|
|
||||||
|
patched, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
|
||||||
|
}
|
||||||
@@ -135,6 +135,11 @@ type Server struct {
|
|||||||
updateManager *updater.Manager
|
updateManager *updater.Manager
|
||||||
|
|
||||||
jwtCache *jwtCache
|
jwtCache *jwtCache
|
||||||
|
|
||||||
|
// loginAttemptFn stands in for the Management login round trip. Tests set
|
||||||
|
// it to drive the login outcomes that need a server on the other end;
|
||||||
|
// production leaves it nil, and every login goes through loginAttempt.
|
||||||
|
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthAuthFlow struct {
|
type oauthAuthFlow struct {
|
||||||
@@ -370,7 +375,19 @@ func (s *Server) connectionGoroutineRunning() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
|
// attemptLogin runs a login round trip against Management, or the stand-in a
|
||||||
|
// test installed in place of it.
|
||||||
|
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||||
|
if s.loginAttemptFn != nil {
|
||||||
|
return s.loginAttemptFn(ctx, setupKey, jwtToken)
|
||||||
|
}
|
||||||
|
return s.loginAttempt(ctx, setupKey, jwtToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginAttempt attempts to login using the provided information. It returns
|
||||||
|
// StatusNeedsLogin when Management refused the peer's credentials and
|
||||||
|
// StatusLoginFailed for every other failure, so callers can tell an
|
||||||
|
// authentication decision apart from a login that never got made.
|
||||||
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||||
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -623,11 +640,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
s.config = config
|
s.config = config
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
|
loginStatus, err := s.attemptLogin(ctx, "", "")
|
||||||
|
if err == nil {
|
||||||
state.Set(internal.StatusIdle)
|
state.Set(internal.StatusIdle)
|
||||||
return &proto.LoginResponse{}, nil
|
return &proto.LoginResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only an authentication refusal means the peer has to (re-)authenticate.
|
||||||
|
// Any other failure leaves the login undecided: Management unreachable, a
|
||||||
|
// restart mid-request, an internal error. Those are returned for the caller
|
||||||
|
// to retry, because turning them into an SSO prompt asks the user to solve
|
||||||
|
// something that is not theirs to solve, and a browser login cannot succeed
|
||||||
|
// while Management is unreachable anyway.
|
||||||
|
if loginStatus != internal.StatusNeedsLogin {
|
||||||
|
state.Set(loginStatus)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if msg.SetupKey == "" {
|
if msg.SetupKey == "" {
|
||||||
hint := ""
|
hint := ""
|
||||||
if msg.Hint != nil {
|
if msg.Hint != nil {
|
||||||
@@ -684,7 +713,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
// which returns NeedsLogin and parks on the browser leg.
|
// which returns NeedsLogin and parks on the browser leg.
|
||||||
state.Set(internal.StatusConnecting)
|
state.Set(internal.StatusConnecting)
|
||||||
|
|
||||||
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
|
||||||
state.Set(loginStatus)
|
state.Set(loginStatus)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -839,7 +868,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
s.oauthAuthFlow.expiresAt = time.Now()
|
s.oauthAuthFlow.expiresAt = time.Now()
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
||||||
state.Set(loginStatus)
|
state.Set(loginStatus)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user