mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-14 10:49:07 +02:00
The browser-login popup was created hidden and relied on its own webview to size and show itself and to launch the external browser. On macOS a hidden WKWebView gets throttled or suspended (App Nap / hidden-window throttling), so on the first-use path nothing appeared and the browser never opened, leaving the session-expiration dialog disabled until the PKCE flow timed out. Reproduced by freezing the popup's WebContent process: the old code showed nothing, the new code shows the popup and opens the browser within 30 ms regardless of the webview state. Show and focus the popup from Go right after creation and launch the browser from Go on both the create and reuse paths. The popup's frontend no longer shows or focuses itself, so the browser keeps the foreground once it activates. This also fixes the reuse path, where a fragment-only SetURL kept the mounted React tree and the once-only guard skipped opening the browser for the new URI. Browser launch failures surface in the error dialog instead of being swallowed.
295 lines
9.2 KiB
Go
295 lines
9.2 KiB
Go
//go:build !android && !ios && !freebsd && !js
|
|
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"runtime"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// LoginParams are the inputs to Login.
|
|
type LoginParams struct {
|
|
ProfileName string `json:"profileName"`
|
|
Username string `json:"username"`
|
|
ManagementURL string `json:"managementUrl"`
|
|
SetupKey string `json:"setupKey"`
|
|
PreSharedKey string `json:"preSharedKey"`
|
|
Hostname string `json:"hostname"`
|
|
Hint string `json:"hint"`
|
|
}
|
|
|
|
// LoginResult is the daemon's reply to Login.
|
|
type LoginResult struct {
|
|
NeedsSSOLogin bool `json:"needsSsoLogin"`
|
|
UserCode string `json:"userCode"`
|
|
VerificationURI string `json:"verificationUri"`
|
|
VerificationURIComplete string `json:"verificationUriComplete"`
|
|
// ProfileID is the ID of the profile this login ran against, or "" when the
|
|
// caller named the profile itself and no ID was resolved. Pass it back in
|
|
// WaitSSOParams so the account email lands on this profile even if the
|
|
// active one changes during SSO.
|
|
ProfileID string `json:"profileId"`
|
|
}
|
|
|
|
// WaitSSOParams are the inputs to waitSSOLogin.
|
|
type WaitSSOParams struct {
|
|
UserCode string `json:"userCode"`
|
|
Hostname string `json:"hostname"`
|
|
// ProfileID is the profile the login was started for, used to file the
|
|
// account email against it rather than against whichever profile is active
|
|
// when the flow returns. Optional: empty falls back to the active profile.
|
|
ProfileID string `json:"profileId"`
|
|
}
|
|
|
|
// UpParams selects the profile to bring up.
|
|
type UpParams struct {
|
|
ProfileName string `json:"profileName"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// LogoutParams selects the profile to log out.
|
|
type LogoutParams struct {
|
|
ProfileName string `json:"profileName"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// Connection groups the daemon RPCs that drive login / connect / disconnect.
|
|
type Connection struct {
|
|
conn DaemonConn
|
|
classifier errorClassifier
|
|
}
|
|
|
|
// NewConnection wires up a Connection. translator or prefs may be nil, in which
|
|
// case classifyDaemonError falls back to the bare error key.
|
|
func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection {
|
|
return &Connection{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
|
}
|
|
|
|
func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) {
|
|
cli, err := s.conn.Client()
|
|
if err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
|
|
// No pre-Login Down: Login dislodges a pending WaitSSOLogin itself, and a
|
|
// defensive Down would only flash an Idle blink in the tray during handoff.
|
|
|
|
// Fall back to the daemon's active profile and the current OS user.
|
|
profileName := p.ProfileName
|
|
username := p.Username
|
|
// Only set when the daemon told us the ID. A caller-supplied ProfileName is
|
|
// a handle — a display name or an ID prefix resolve too — and the state file
|
|
// is named after the ID, so passing a handle on would name the wrong file.
|
|
profileID := ""
|
|
if profileName == "" {
|
|
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
|
// Address the active profile by ID (the daemon resolves it as a
|
|
// handle); names can collide, the ID cannot.
|
|
profileName = active.GetId()
|
|
profileID = profileName
|
|
if username == "" {
|
|
username = active.GetUsername()
|
|
}
|
|
}
|
|
}
|
|
if username == "" {
|
|
if u, uerr := user.Current(); uerr == nil {
|
|
username = u.Username
|
|
}
|
|
}
|
|
|
|
req := &proto.LoginRequest{
|
|
ManagementUrl: p.ManagementURL,
|
|
SetupKey: p.SetupKey,
|
|
Hostname: p.Hostname,
|
|
// a login driven by the UI always has a graphical session available
|
|
IsUnixDesktopClient: true,
|
|
}
|
|
if profileName != "" {
|
|
req.ProfileName = ptrStr(profileName)
|
|
}
|
|
if username != "" {
|
|
req.Username = ptrStr(username)
|
|
}
|
|
if p.PreSharedKey != "" {
|
|
req.OptionalPreSharedKey = ptrStr(p.PreSharedKey)
|
|
}
|
|
hint := p.Hint
|
|
if hint == "" && profileID != "" {
|
|
if state, serr := profilemanager.NewProfileManager().GetProfileState(profilemanager.ID(profileID)); serr == nil {
|
|
hint = state.Email
|
|
} else {
|
|
log.Debugf("failed to get profile state for login hint: %v", serr)
|
|
}
|
|
}
|
|
if hint != "" {
|
|
req.Hint = ptrStr(hint)
|
|
}
|
|
|
|
resp, err := cli.Login(ctx, req)
|
|
if err != nil {
|
|
return LoginResult{}, s.classifyDaemonError(err)
|
|
}
|
|
log.Infof("daemon login response received, needs SSO login: %v", resp.GetNeedsSSOLogin())
|
|
return LoginResult{
|
|
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
|
|
UserCode: resp.GetUserCode(),
|
|
VerificationURI: resp.GetVerificationURI(),
|
|
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
|
ProfileID: profileID,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
|
cli, err := s.conn.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Infof("sending up request to daemon")
|
|
// Always async: status updates flow via SubscribeStatus.
|
|
req := &proto.UpRequest{Async: true}
|
|
if p.ProfileName != "" {
|
|
req.ProfileName = ptrStr(p.ProfileName)
|
|
}
|
|
if p.Username != "" {
|
|
req.Username = ptrStr(p.Username)
|
|
}
|
|
if _, err = cli.Up(ctx, req); err != nil {
|
|
return s.classifyDaemonError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WaitSSOLoginAndUp blocks until the SSO login completes and then brings the
|
|
// connection up, both from the Go side. Keeping the post-login Up here rather
|
|
// than as a frontend continuation is deliberate: during SSO the tray window is
|
|
// hidden and the webview is suspended (macOS App Nap / hidden-window timer
|
|
// throttling), so a frontend-driven Up would not run until the user woke the
|
|
// window (e.g. by hovering the tray icon). Doing it in Go connects the moment
|
|
// the daemon reports SSO success. Returns the authenticated user's email.
|
|
func (s *Connection) WaitSSOLoginAndUp(ctx context.Context, wait WaitSSOParams, up UpParams) (string, error) {
|
|
email, err := s.waitSSOLogin(ctx, wait)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := s.Up(ctx, up); err != nil {
|
|
return "", err
|
|
}
|
|
return email, nil
|
|
}
|
|
|
|
func (s *Connection) Down(ctx context.Context) error {
|
|
cli, err := s.conn.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil {
|
|
return s.classifyDaemonError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// OpenURL opens url in an external browser; the embedded webview blocks
|
|
// window.open, so the SSO verification page can't pop inline. Honors $BROWSER
|
|
// before the platform default.
|
|
func (s *Connection) OpenURL(url string) error {
|
|
return openURL(url)
|
|
}
|
|
|
|
func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
|
cli, err := s.conn.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req := &proto.LogoutRequest{}
|
|
if p.ProfileName != "" {
|
|
req.ProfileName = ptrStr(p.ProfileName)
|
|
}
|
|
if p.Username != "" {
|
|
req.Username = ptrStr(p.Username)
|
|
}
|
|
if _, err = cli.Logout(ctx, req); err != nil {
|
|
return s.classifyDaemonError(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// waitSSOLogin blocks until the daemon reports the SSO login result and returns
|
|
// the authenticated user's email. It is unexported because the frontend drives
|
|
// SSO through the exported WaitSSOLoginAndUp.
|
|
func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
|
|
cli, err := s.conn.Client()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
log.Infof("waiting for SSO login to complete")
|
|
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
|
|
UserCode: p.UserCode,
|
|
Hostname: p.Hostname,
|
|
})
|
|
if err != nil {
|
|
return "", s.classifyDaemonError(err)
|
|
}
|
|
log.Infof("SSO login completed, daemon reported success")
|
|
|
|
// Persist the account email the same way the CLI does after its own
|
|
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
|
|
// root and the per-profile state file is user-owned (see Profiles.List).
|
|
// Without this the profile has no email, so Profiles.List shows no account
|
|
// and later logins and session extends go out without a login_hint —
|
|
// leaving the IdP to guess which account was meant.
|
|
if email := resp.GetEmail(); email != "" {
|
|
state := &profilemanager.ProfileState{Email: email}
|
|
pm := profilemanager.NewProfileManager()
|
|
|
|
// Against the profile the login was started for: SSO spans seconds of
|
|
// user interaction, and a profile switch in that window would otherwise
|
|
// file the email under the wrong profile.
|
|
if p.ProfileID != "" {
|
|
err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state)
|
|
} else {
|
|
err = pm.SetActiveProfileState(state)
|
|
}
|
|
if err != nil {
|
|
// Non-fatal: the login itself succeeded.
|
|
log.Warnf("failed to store account email: %v", err)
|
|
}
|
|
}
|
|
|
|
return resp.GetEmail(), nil
|
|
}
|
|
|
|
// classifyDaemonError maps a gRPC error to a localised ClientError.
|
|
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
|
return s.classifier.classify(err)
|
|
}
|
|
|
|
func openURL(url string) error {
|
|
if browser := os.Getenv("BROWSER"); browser != "" {
|
|
return exec.Command(browser, url).Start()
|
|
}
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
|
case "darwin":
|
|
return exec.Command("open", url).Start()
|
|
case "linux":
|
|
return exec.Command("xdg-open", url).Start()
|
|
default:
|
|
return fmt.Errorf("unsupported platform")
|
|
}
|
|
}
|