mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-23 15:19:08 +02:00
The mobile bridges only carried the policy fetcher, leaving every enforcement decision to the native apps: the desktop derived its UI restrictions in the Wails service layer, the daemon kept the conflict machinery in the server package, and both mobile bridges duplicated the JSON fetch adapter. Anything the native side had to reimplement was a place for iOS and Android to drift apart. Enforcement now lives in client/mdm and is consumed identically by all three platforms: - conflicts.go holds the value-aware conflict checks lifted out of the daemon, so the same normalization (canonical URLs, PSK sentinel echo) applies wherever a config change is validated. - restrictions.go derives the UI enforcement snapshot from a policy and renders it in the JSON shape the desktop frontend already consumes. The service-layer types become aliases, keeping one source of truth. - jsonloader.go replaces the adapter that was copy-pasted into both bridges. - changedetector.go moves change detection off the native side: the caller forwards the OS notification and asks whether the managed configuration actually changed, instead of diffing dictionaries itself. The mobile bridges gain the enforcement the daemon already had. The Preferences getters resolve managed keys from the policy, so a naive UI shows the enforced value; Commit rejects a staged change that diverges from a managed key; NewAuth resolves the managed management URL before persisting the config and overlays the policy on it, so a login can no longer run against a URL the policy forbids. Android's profile mutations fail closed when disableProfiles is set. NewAuth takes the fetcher as a required argument rather than keeping a policy-blind overload: the apps consume this code as a submodule, so a compile error at the bump is the point. The mobile PSK getter is replaced by a presence check — the key has no reason to cross the bridge, and not returning it means the native side needs no redaction sentinel of its own.
383 lines
13 KiB
Go
383 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/mdm"
|
|
"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.
|
|
// Auth is constructed under the active MDM policy: a managed management URL
|
|
// replaces the caller-supplied one before the config is persisted, and the
|
|
// policy is overlaid on the resolved config so the login runs against the
|
|
// enforced values. A nil fetcher disables MDM enforcement.
|
|
func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) {
|
|
policy := loaderFor(fetcher).Load()
|
|
if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
|
|
mgmURL = v
|
|
}
|
|
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
|
|
}
|
|
cfg.ApplyMDMPolicy(policy)
|
|
|
|
// 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
|
|
}
|