Files
netbird/client/ui/services/connection.go
Zoltan Papp 2f721ec0d5 [client] Keep the account email backing the SSO login hint correct (#6986)
## Describe your changes

Three fixes to how the desktop GUI keeps the account email that backs
the SSO `login_hint`. Each is independent and reviewable on its own.

**1. Store the email after a GUI SSO login**

The daemon returns the authenticated user's email from `WaitSSOLogin`
but cannot persist it: it runs as root while the per-profile state file
is user-owned. The CLI's `handleSSOLogin` writes it after its own
`WaitSSOLogin`; the GUI path read the value and dropped it.

The profile was therefore left with no email, so `Profiles.List` showed
no account for it, and later logins and session extends went out with no
`login_hint` — leaving the IdP to pick an account instead of reusing the
one the profile belongs to. Mirror the CLI and store it, next to the
`Logout` path that already clears the same file for the same reason.

**2. File the email against the profile the login ran for**

`SetActiveProfileState` resolves the target itself, so it writes to
whichever profile is active when it is called. A GUI SSO login spans
seconds of user interaction in the browser, and the tray stays clickable
throughout: switching profiles in that window left the email filed under
the profile that happened to be active when the flow returned. The wrong
profile then advertised an account it does not own, and offered it as
the `login_hint` next time.

Adds `SetProfileState(id, state)`, the write-side counterpart of the
existing `GetProfileState(id)`, and keeps `SetActiveProfileState` as a
wrapper for callers with no particular profile in mind. `Login` now
reports the profile it resolved so the frontend can hand it back with
the SSO wait, which closes the window.

**3. Delete the email when a profile is removed**

Removing a profile left its state file behind: the daemon deletes what
it owns, but the email file is user-owned and out of reach for a root
daemon — the same split that already puts the `Logout` cleanup on the UI
side.

Beyond the stray file, legacy profiles are keyed by name rather than by
a generated ID, so recreating a profile under a removed one's name
inherited its email — shown as the account in the profile list and sent
as the `login_hint` on the next login.

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- SSO login details can now be saved to the specific profile selected
during sign-in.
  - Profile state can be managed independently for different profiles.

- **Bug Fixes**
  - Removing a profile now also cleans up its associated saved state.
- Cleanup issues no longer prevent successful profile removal and are
handled gracefully.
  - Existing active-profile behavior remains unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 14:21:40 +02:00

292 lines
9.3 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,
IsUnixDesktopClient: runtime.GOOS == "linux",
}
if profileName != "" {
req.ProfileName = ptrStr(profileName)
}
if username != "" {
req.Username = ptrStr(username)
}
if p.PreSharedKey != "" {
req.OptionalPreSharedKey = ptrStr(p.PreSharedKey)
}
if p.Hint != "" {
req.Hint = ptrStr(p.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 {
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")
}
}
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)
}
// The daemon runs as root and can't reach the user-owned per-profile state
// file holding the account email (see Profiles.List), so clear the stale
// email here; the next SSO login recreates it.
if p.ProfileName != "" {
if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil {
// Non-fatal: the logout itself succeeded.
log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, 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 Logout below).
// 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)
}