package android import ( "context" "fmt" 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 config *profilemanager.Config cfgPath string } // NewAuth instantiate Auth struct and validate the management URL // // The configuration at cfgPath is reused when one is already there, and only created when it is // not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on // every call: the peer registers under that key, the key is written out, and any peer registered by // an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from // the persisted config, because the identity it registered is not the one it runs with — the // management stream rejects it with "no peer auth method provided". func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { inputCfg := profilemanager.ConfigInput{ ConfigPath: cfgPath, ManagementURL: mgmURL, } cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg) if err != nil { return nil, err } return &Auth{ ctx: context.Background(), config: cfg, cfgPath: cfgPath, }, nil } // NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the // file the config was loaded from; it identifies the profile whose account email // backs the login_hint. func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth { return &Auth{ ctx: ctx, config: config, cfgPath: cfgPath, } } // 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) { 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 } err = profilemanager.WriteOutConfig(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) { 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 profilemanager.WriteOutConfig(a.cfgPath, a.config) } // Login try register the client on the server func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, isAndroidTV bool) { go func() { err := a.login(urlOpener, isAndroidTV) if err != nil { resultListener.OnError(err) } else { resultListener.OnSuccess() } }() } func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) 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 := "" email := "" if needsLogin { tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } jwtToken = tokenInfo.GetTokenToUse() email = tokenInfo.Email } err, _ = authClient.Login(a.ctx, "", jwtToken) if err != nil { 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 := writeProfileEmail(a.cfgPath, email); err != nil { log.Warnf("failed to store profile account email: %v", err) } } go urlOpener.OnLoginSuccess() return nil } func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) { oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath)) if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil) } // profileLoginHint returns the stored account email for the profile at cfgPath. // 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 readProfileEmail(cfgPath) } // runOAuthFlow drives an already acquired OAuth flow to a token: requests the // flow info, presents the verification URL through the opener and waits for // the browser round-trip. Open is called synchronously — it is what marks the // surface as opened on the client side, and a fast token's OnLoginSuccess is // a no-op until it has, so the dismissal would be dropped rather than // delayed. Openers must therefore not block: they post their UI work and // return. onWaiting, when set, runs after the URL is shown, right before the // blocking wait. func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) { flowInfo, err := flow.RequestAuthInfo(ctx) if err != nil { return nil, fmt.Errorf("request auth info: %w", err) } urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) if onWaiting != nil { onWaiting() } tokenInfo, err := flow.WaitToken(ctx, flowInfo) if err != nil { return nil, fmt.Errorf("wait for token: %w", err) } return &tokenInfo, nil }