mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-26 00:29:06 +02:00
Key generation used to happen inside apply(), so a config loaded from JSON with no keys got them in memory on the way in, the login worked, and the app stored the result. This branch moved generation into EnsureIdentity, and nothing in the iOS SDK called it. The consequence lands on the flow the mobile logout sets up: logout clears both keys in place so the next login registers a new peer. The app then hands that keyless JSON to Auth.SetConfigFromJSON, and the login calls auth.NewAuth with an empty WireGuard key, which fails on key size before the SSO flow starts — the user cannot sign back in. Auth.setBaseConfig now provisions, which covers both entry points (NewAuth and SetConfigFromJSON). It mints on the base config, the one GetConfigJSON returns for the caller to persist, and writes it to disk itself when the profile has a file — non-atomically, like NewAuth's own write, since the tvOS App Group sandbox blocks temp-file-and-rename. Not covered by a test: the package builds only under GOOS=ios, which the test jobs do not run. Verified by building and vetting for GOOS=ios/arm64. Reported by pappz in review.
430 lines
14 KiB
Go
430 lines
14 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/mobile"
|
|
"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
|
|
base *profilemanager.Config
|
|
policy *mdm.Policy
|
|
cfgPath string
|
|
}
|
|
|
|
// NewAuth instantiate Auth struct and validate the management URL.
|
|
// Auth is constructed under the active MDM policy: the policy is overlaid on
|
|
// the resolved config so the login runs against the enforced values, while
|
|
// the persisted config keeps the caller-supplied ones; a caller-supplied
|
|
// management URL is ignored while MDM manages that key. A nil fetcher
|
|
// disables MDM enforcement.
|
|
func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) {
|
|
policy := loaderFor(fetcher).Load()
|
|
inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath}
|
|
if _, managed := policy.GetString(mdm.KeyManagementURL); !managed {
|
|
inputCfg.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
|
|
}
|
|
a := &Auth{policy: policy, cfgPath: cfgPath}
|
|
if err := a.setBaseConfig(cfg); 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.
|
|
a.ctx, a.cancel = context.WithCancel(context.Background())
|
|
return a, 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 reports whether the management server supports SSO; the config is already persisted by NewAuth.
|
|
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)
|
|
}
|
|
|
|
return supportsSSO, nil
|
|
}
|
|
|
|
// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth.
|
|
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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 := ""
|
|
email := ""
|
|
if needsLogin {
|
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth)
|
|
if err != nil {
|
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
|
}
|
|
jwtToken = tokenInfo.GetTokenToUse()
|
|
email = tokenInfo.Email
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Stored after Login, not before: a rejected token must not leave a hint
|
|
// pointing at an account that cannot be used.
|
|
if email != "" && a.cfgPath != "" {
|
|
if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil {
|
|
log.Warnf("failed to store profile account email: %v", err)
|
|
}
|
|
}
|
|
|
|
// Notify caller of successful login synchronously before returning
|
|
urlOpener.OnLoginSuccess()
|
|
|
|
return nil
|
|
}
|
|
|
|
// profileLoginHint returns the stored account email for the profile at cfgPath,
|
|
// so a re-login targets the account the profile already belongs to instead of
|
|
// whatever session the shared browser cookie jar happens to hold.
|
|
//
|
|
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
|
|
// choice to the IdP. Switching accounts is done by switching or removing
|
|
// profiles, not by logging out — logout keeps the email.
|
|
func profileLoginHint(cfgPath string) string {
|
|
if cfgPath == "" {
|
|
return ""
|
|
}
|
|
return mobile.ReadProfileEmail(cfgPath)
|
|
}
|
|
|
|
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, profileLoginHint(a.cfgPath))
|
|
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 config without the MDM overlay as JSON, for persisting it outside the config file (tvOS).
|
|
func (a *Auth) GetConfigJSON() (string, error) {
|
|
cfg := a.base
|
|
if cfg == nil {
|
|
cfg = a.config
|
|
}
|
|
if cfg == nil {
|
|
return "", fmt.Errorf("no config available")
|
|
}
|
|
return profilemanager.ConfigToJSON(cfg)
|
|
}
|
|
|
|
// SetConfigFromJSON replaces the config from JSON; the MDM overlay is applied on top for the login.
|
|
func (a *Auth) SetConfigFromJSON(jsonStr string) error {
|
|
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.setBaseConfig(cfg)
|
|
}
|
|
|
|
func (a *Auth) setBaseConfig(base *profilemanager.Config) error {
|
|
// A logged-out profile carries no keys: the mobile logout clears them in
|
|
// place so the next login registers a new peer instead of resurrecting the
|
|
// old one. This is that login, and auth.NewAuth parses the WireGuard key
|
|
// before the SSO flow even starts, so an absent identity fails the login on
|
|
// key size rather than asking the user to sign in.
|
|
//
|
|
// Minted on the base config, which is the one GetConfigJSON hands back for
|
|
// the caller to store — the overlaid copy below is runtime-only.
|
|
generated, err := base.EnsureIdentity()
|
|
if err != nil {
|
|
return fmt.Errorf("ensure profile identity: %w", err)
|
|
}
|
|
if generated {
|
|
if a.cfgPath != "" {
|
|
// Non-atomic, like NewAuth's own write: the tvOS App Group sandbox
|
|
// blocks the temp-file-and-rename an atomic write needs.
|
|
if err := profilemanager.DirectWriteOutConfig(a.cfgPath, base); err != nil {
|
|
return fmt.Errorf("write out profile config: %w", err)
|
|
}
|
|
} else {
|
|
// No file to write to — this is the tvOS path, where the profile
|
|
// lives in the caller's own store. It persists the new identity by
|
|
// calling GetConfigJSON once the login completes; until then the
|
|
// keys exist only here, and a login that never completes leaves
|
|
// nothing behind.
|
|
log.Infof("provisioned a peer identity for a config with no file on disk")
|
|
}
|
|
}
|
|
|
|
overlaid, err := copyConfig(base)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if a.policy != nil {
|
|
overlaid.ApplyMDMPolicy(a.policy)
|
|
}
|
|
a.base = base
|
|
a.config = overlaid
|
|
return nil
|
|
}
|
|
|
|
func copyConfig(cfg *profilemanager.Config) (*profilemanager.Config, error) {
|
|
raw, err := profilemanager.ConfigToJSON(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return profilemanager.ConfigFromJSON(raw)
|
|
}
|