mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-20 15:31:30 +02:00
## sync 0.74.6 fix/ios-relogin NewAuth built a fresh in-memory config on every call via CreateInMemoryConfig, which generates a new WireGuard private key when none is set. The iOS Swift layer calls this on interactive re-login and writes the resulting config back to the profile's netbird.cfg, so each re-auth replaced the peer's persisted private key with a new one. A new key means a new public key, so the management server registered a brand-new peer on every re-authentication — named after the fallback hostname. Load the existing config with DirectUpdateOrCreateConfig when a config file is already present so re-login reuses the peer's persisted private key (and its identity). Only fall back to a fresh in-memory config for the first-time login when no config file exists yet (or after logout, which deletes the file). DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside the tvOS App Group sandbox. This matches what Run() and LoginForMobile() already do. ## Describe your changes ## 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/6795"><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 Codesmith" 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=1786784867&installation_id=146802194&pr_number=6795&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6795&signature=01bd94ea58128257c33f4d430ab3b71ce21eac02577ce90c009105b8c0027d9c"><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 Codesmith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a> <sup>Need help on this PR? Tag <code>/codesmith</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 - **Bug Fixes** - Improved iOS login handling when a configuration location is provided. - Existing WireGuard keys can now be reused across subsequent logins, helping avoid unnecessary key regeneration. - Login continues to support temporary in-memory configuration when no persistent location is available. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
351 lines
11 KiB
Go
351 lines
11 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) {
|
|
if resultListener == nil {
|
|
log.Errorf("LoginWithDeviceName: resultListener is nil")
|
|
return
|
|
}
|
|
if urlOpener == nil {
|
|
log.Errorf("LoginWithDeviceName: urlOpener is nil")
|
|
resultListener.OnError(fmt.Errorf("urlOpener is nil"))
|
|
return
|
|
}
|
|
go func() {
|
|
err := a.login(urlOpener, forceDeviceAuth, deviceName)
|
|
if err != nil {
|
|
resultListener.OnError(err)
|
|
} else {
|
|
resultListener.OnSuccess()
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) 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
|
|
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
|
|
}
|