Files
netbird/client/ios/NetBirdSDK/login.go
evgeniyChepelev f2318a8fef [client] iOS - Remove duplicate Login RPCs from the iOS SDK (#6931)
## Describe your changes

Removes redundant `Login` RPCs from the iOS SDK bindings.

## 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/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6931"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787779607&installation_model_id=427504&pr_number=6931&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6931&signature=ce1631be2a5ffdba58c44b4669b0d480dc9f956102cf10a0c7b5845a800cdc68"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

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

## Summary by CodeRabbit

* **New Features**
* Added an interactive iOS login option that starts authentication
directly when needed.
* Improved login flow handling, including clearer error reporting and
successful-login notifications.
* Login configuration is now saved automatically after successful
authentication when applicable.

* **Bug Fixes**
  * Prevented duplicate login requests during iOS startup.
* Improved startup behavior and error propagation when the management
service is unavailable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Removes redundant `Login` RPCs from the iOS SDK bindings. Both changed
files are behind
the `ios` build tag — Android, desktop and the shared core are not
affected.

### Problem

`auth.Auth.IsLoginRequired()` is not a cheap probe: it calls
`doMgmLogin()` and classifies
the resulting error, so every "is login required?" check costs a **full
`Login` RPC**. There
is no lighter way to ask. As a result the iOS client issued ~7 `Login`
requests before the
first `Sync`, where Android issues ~3, and the extra ones were
indistinguishable from real
logins in the management logs.

Three of those came from this package:

1. `Run()` called `LoginSync()` before starting the engine, which
performs `IsLoginRequired`
**and** `Login` — two RPCs. This duplicated the engine's own
`loginToManagement`
(`client/internal/connect.go`), which runs immediately before the first
`Sync` and is the
authoritative login. The `Login(ctx, "", "")` inside `LoginSync` could
not even establish
anything: with an empty setup key and empty JWT, a registration attempt
fails by
   construction, so it was a pure check.
2. `Auth.login()` called `IsLoginRequired()` again before opening the
browser, even when the
   caller had already determined that login is needed.

This is not only wasted traffic:

- **It pushes peers toward the server-side login ban.** In
`management/internals/shared/grpc/loginfilter.go`, every login with
unchanged metadata
increments `sessionCounter`, and exceeding `reconnLimitForBan` (30)
within
`reconnThreshold` (5 min) bans the peer for `baseBlockDuration` (10
min), doubling on
repeat. Redundant logins carry identical metadata, so they count against
exactly this
budget. At 7 logins per connect the budget is exhausted after ~4
reconnects instead of
  ~10 — reachable on flaky mobile networks.
- **Each redundant check is a potential 2-minute stall.**
`IsLoginRequired` retries with
backoff up to `MaxElapsedTime` (2 min) and returns `true` on failure, so
an unreachable
server was reported as "login required" rather than as a timeout, and
the `LoginSync`
  pre-flight could abort engine startup on that basis.

### Changes

**`client/ios/NetBirdSDK/client.go`** — `Run()` no longer performs the
`LoginSync()`
pre-flight. The engine's `loginToManagement` remains the single
authoritative login.

**`client/ios/NetBirdSDK/login.go`** — new exported `LoginInteractive`,
which skips the
`IsLoginRequired()` pre-flight and goes straight to the browser /
device-code flow, for
callers that have already established login is required.
`LoginWithDeviceName` keeps the
check for callers where the auth state is unknown (tvOS). Both now
delegate to a shared
`startLogin()`.

### Why this is safe

An expired or revoked session still fails the connection, one step later
and through a
single path: `loginToManagement` returns `PermissionDenied` → the
deferred
`MarkManagementDisconnected` records it on the shared status recorder →
`ClientStop` fires
the listener's disconnect callback, where `IsLoginRequiredCached()`
reports login-required →
the client tears the tunnel down. The error is also returned out of
`Run()`.

Where the server is unreachable, the engine now retries with backoff and
recovers on its
own instead of aborting the start.

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
2026-08-02 09:51:14 +02:00

373 lines
13 KiB
Go

//go:build ios
package NetBirdSDK
import (
"context"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/system"
)
// SSOListener is async listener for mobile framework
type SSOListener interface {
OnSuccess(bool)
OnError(error)
}
// ErrListener is async listener for mobile framework
type ErrListener interface {
OnSuccess()
OnError(error)
}
// URLOpener it is a callback interface. The Open function will be triggered if
// the backend want to show an url for the user
type URLOpener interface {
Open(url string, userCode string)
OnLoginSuccess()
}
// Auth can register or login new client
type Auth struct {
ctx context.Context
cancel context.CancelFunc
config *profilemanager.Config
cfgPath string
}
// NewAuth instantiate Auth struct and validate the management URL
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
inputCfg := profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: mgmURL,
}
// Load the existing config when a config file is already present so an
// interactive re-login reuses the peer's persisted WireGuard private key
// (and thus its identity) instead of generating a fresh one. Generating a
// new key registers a brand-new peer on the management server on every
// re-auth (named after the fallback hostname). Only fall back to a fresh
// in-memory config for the first-time login when no config file exists yet.
// DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside
// the tvOS App Group sandbox where atomic temp-file+rename is blocked.
var cfg *profilemanager.Config
var err error
if cfgPath != "" {
cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg)
} else {
cfg, err = profilemanager.CreateInMemoryConfig(inputCfg)
}
if err != nil {
return nil, err
}
// Use a cancellable context so Stop() can abort an in-progress interactive
// login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
// bound to a port) until the OAuth callback arrives or the flow expires;
// cancelling the context unblocks WaitToken, which then shuts that server down
// and frees the port for the next login attempt. iOS runs login in the main-app
// process (decoupled from the network extension), so without this the server
// lingers after the user dismisses the browser and the next connect stalls
// trying to bind the same port.
ctx, cancel := context.WithCancel(context.Background())
return &Auth{
ctx: ctx,
cancel: cancel,
config: cfg,
cfgPath: cfgPath,
}, nil
}
// NewAuthWithConfig instantiate Auth based on existing config
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
ctx, cancel := context.WithCancel(ctx)
return &Auth{
ctx: ctx,
cancel: cancel,
config: config,
}
}
// Stop aborts an in-progress interactive login started via Login/LoginWithDeviceName.
// It cancels the auth context, which unblocks the PKCE WaitToken and shuts down its
// loopback HTTP server, freeing the redirect port. Safe to call multiple times and
// safe to call when no login is running.
func (a *Auth) Stop() {
if a.cancel != nil {
a.cancel()
}
}
// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info.
// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO
// is not supported and returns false without saving the configuration. For other errors return false.
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
if listener == nil {
log.Errorf("SaveConfigIfSSOSupported: listener is nil")
return
}
go func() {
sso, err := a.saveConfigIfSSOSupported()
if err != nil {
listener.OnError(err)
} else {
listener.OnSuccess(sso)
}
}()
}
func (a *Auth) saveConfigIfSSOSupported() (bool, error) {
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
if err != nil {
return false, fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
supportsSSO, err := authClient.IsSSOSupported(a.ctx)
if err != nil {
return false, fmt.Errorf("failed to check SSO support: %v", err)
}
if !supportsSSO {
return false, nil
}
// Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename)
// which are blocked by the tvOS sandbox in App Group containers
err = profilemanager.DirectWriteOutConfig(a.cfgPath, a.config)
return true, err
}
// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key.
func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) {
if resultListener == nil {
log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil")
return
}
go func() {
err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName)
if err != nil {
resultListener.OnError(err)
} else {
resultListener.OnSuccess()
}
}()
}
func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string) error {
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
if err != nil {
return fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
//nolint
ctxWithValues := context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName)
err, _ = authClient.Login(ctxWithValues, setupKey, "")
if err != nil {
return fmt.Errorf("login failed: %v", err)
}
// Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename)
// which are blocked by the tvOS sandbox in App Group containers
return profilemanager.DirectWriteOutConfig(a.cfgPath, a.config)
}
// LoginSync performs a synchronous login check without UI interaction
// Used for background VPN connection where user should already be authenticated
func (a *Auth) LoginSync() error {
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
if err != nil {
return fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
// check if we need to generate JWT token
needsLogin, err := authClient.IsLoginRequired(a.ctx)
if err != nil {
return fmt.Errorf("failed to check login requirement: %v", err)
}
jwtToken := ""
if needsLogin {
return fmt.Errorf("not authenticated")
}
err, isAuthError := authClient.Login(a.ctx, "", jwtToken)
if err != nil {
if isAuthError {
// PermissionDenied means registration is required or peer is blocked
return fmt.Errorf("authentication error: %v", err)
}
return fmt.Errorf("login failed: %v", err)
}
return nil
}
// Login performs interactive login with device authentication support
// Deprecated: Use LoginWithDeviceName instead to ensure proper device naming on tvOS
func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool) {
// Use empty device name - system will use hostname as fallback
a.LoginWithDeviceName(resultListener, urlOpener, forceDeviceAuth, "")
}
// LoginWithDeviceName performs interactive login with device authentication support
// The deviceName parameter allows specifying a custom device name (required for tvOS)
func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, false)
}
// LoginInteractive performs the same interactive login as LoginWithDeviceName but skips the
// IsLoginRequired() pre-flight and goes straight to the browser / device-code flow.
//
// IsLoginRequired() is itself a full Login RPC against the management server, so when the
// caller has ALREADY established that login is required it is a pure duplicate. On iOS the
// main app decides to show the browser based on its own isLoginRequired() check and then
// calls straight into this method, so re-asking the server would add another Login RPC to
// every interactive login.
//
// Use LoginWithDeviceName when the auth state is unknown and a silent (browser-less) login
// must still be possible; use this when the browser is going to be shown regardless.
func (a *Auth) LoginInteractive(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, true)
}
func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) {
if resultListener == nil {
log.Errorf("startLogin: resultListener is nil")
return
}
if urlOpener == nil {
log.Errorf("startLogin: urlOpener is nil")
resultListener.OnError(fmt.Errorf("urlOpener is nil"))
return
}
go func() {
err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck)
if err != nil {
resultListener.OnError(err)
} else {
resultListener.OnSuccess()
}
}()
}
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error {
// Create context with device name if provided
ctx := a.ctx
if deviceName != "" {
//nolint:staticcheck
ctx = context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName)
}
authClient, err := auth.NewAuth(ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
if err != nil {
return fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
// check if we need to generate JWT token (skipped when the caller already knows)
needsLogin := true
if !skipLoginCheck {
needsLogin, err = authClient.IsLoginRequired(ctx)
if err != nil {
return fmt.Errorf("failed to check login requirement: %v", err)
}
}
jwtToken := ""
if needsLogin {
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
jwtToken = tokenInfo.GetTokenToUse()
}
err, isAuthError := authClient.Login(ctx, "", jwtToken)
if err != nil {
if isAuthError {
// PermissionDenied means registration is required or peer is blocked
return fmt.Errorf("authentication error: %v", err)
}
return fmt.Errorf("login failed: %v", err)
}
// Save the config before notifying success to ensure persistence completes
// before the callback potentially triggers teardown on the Swift side.
// Note: This differs from Android which doesn't save config after login.
// On iOS/tvOS, we save here because:
// 1. The config may have been modified during login (e.g., new tokens)
// 2. On tvOS, the Network Extension context may be the only place with
// write permissions to the App Group container
if a.cfgPath != "" {
if err := profilemanager.DirectWriteOutConfig(a.cfgPath, a.config); err != nil {
log.Warnf("failed to save config after login: %v", err)
}
}
// Notify caller of successful login synchronously before returning
urlOpener.OnLoginSuccess()
return nil
}
const authInfoRequestTimeout = 30 * time.Second
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
// Use a bounded timeout for the auth info request to prevent indefinite hangs
authInfoCtx, authInfoCancel := context.WithTimeout(a.ctx, authInfoRequestTimeout)
defer authInfoCancel()
flowInfo, err := oAuthFlow.RequestAuthInfo(authInfoCtx)
if err != nil {
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
}
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
waitTimeout := time.Duration(flowInfo.ExpiresIn) * time.Second
waitCTX, cancel := context.WithTimeout(a.ctx, waitTimeout)
defer cancel()
tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo)
if err != nil {
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
}
return &tokenInfo, nil
}
// GetConfigJSON returns the current config as a JSON string.
// This can be used by the caller to persist the config via alternative storage
// mechanisms (e.g., UserDefaults on tvOS where file writes are blocked).
func (a *Auth) GetConfigJSON() (string, error) {
if a.config == nil {
return "", fmt.Errorf("no config available")
}
return profilemanager.ConfigToJSON(a.config)
}
// SetConfigFromJSON loads config from a JSON string.
// This can be used to restore config from alternative storage mechanisms.
func (a *Auth) SetConfigFromJSON(jsonStr string) error {
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
if err != nil {
return err
}
a.config = cfg
return nil
}