mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 11:45:09 -04:00
The Android binding never recorded which account a profile belongs to, so every interactive login and every session extend went to the IdP with no login_hint. With nothing to go on the IdP picks an account itself, which on a session extend means re-authenticating an account the profile is already signed in with. Store the email the PKCE flow already parses out of the ID token, and pass it back as the hint on later flows. An empty hint stays meaningful: a fresh profile, or one that was logged out, deliberately leaves the choice to the IdP, which is how a profile changes accounts. Logout clears the stored email for that reason — while it is on disk it would steer the next login straight back into the account just logged out of. The email is keyed off the profile's config path rather than the active profile: Auth.login runs in a goroutine, so the active profile can change under a flow already in flight. It lands in <profile>.account.json, not the <profile>.state.json desktop uses for the same data — there the email and the engine's state manager sit in different directories, but on Android both resolve under files/, and the state manager rewrites the whole file from its own keys. ## 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/__ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added account email and active-status details to Android profile information. * Improved SSO sign-in and session renewal by restoring the previously used account as a login hint. * Added Android-specific profile email persistence with automatic cleanup on logout. * **Bug Fixes** * Profile email persistence failures now generate warnings without blocking login or logout. * Improved handling of missing or unreadable account data and repeated logout cleanup. * **Tests** * Added coverage for account-file naming, email persistence, and logout behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
293 lines
9.9 KiB
Go
293 lines
9.9 KiB
Go
//go:build android
|
|
|
|
package android
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
)
|
|
|
|
const (
|
|
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
|
androidUsername = "android"
|
|
)
|
|
|
|
// Profile represents a profile for gomobile
|
|
type Profile struct {
|
|
ID string
|
|
Name string
|
|
// Email is the account this profile last logged in with, "" if it never
|
|
// completed an SSO login or was logged out. See profile_state.go.
|
|
Email string
|
|
IsActive bool
|
|
}
|
|
|
|
// ProfileArray wraps profiles for gomobile compatibility
|
|
type ProfileArray struct {
|
|
items []*Profile
|
|
}
|
|
|
|
// Length returns the number of profiles
|
|
func (p *ProfileArray) Length() int {
|
|
return len(p.items)
|
|
}
|
|
|
|
// Get returns the profile at index i
|
|
func (p *ProfileArray) Get(i int) *Profile {
|
|
if i < 0 || i >= len(p.items) {
|
|
return nil
|
|
}
|
|
return p.items[i]
|
|
}
|
|
|
|
/*
|
|
|
|
/data/data/io.netbird.client/files/ ← configDir parameter
|
|
├── netbird.cfg ← Default profile config
|
|
├── state.json ← Default profile state
|
|
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
|
|
└── profiles/ ← Subdirectory for non-default profiles
|
|
├── work.json ← Legacy work profile config
|
|
├── work.state.json ← Legacy work profile state
|
|
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
|
|
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
|
|
*/
|
|
|
|
// ProfileManager manages profiles for Android
|
|
// It wraps the internal profilemanager to provide Android-specific behavior
|
|
type ProfileManager struct {
|
|
configDir string
|
|
serviceMgr *profilemanager.ServiceManager
|
|
}
|
|
|
|
// NewProfileManager creates a new profile manager for Android
|
|
func NewProfileManager(configDir string) *ProfileManager {
|
|
// Set the default config path for Android (stored in root configDir, not profiles/)
|
|
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
|
|
|
|
// Set global paths for Android
|
|
profilemanager.DefaultConfigPathDir = configDir
|
|
profilemanager.DefaultConfigPath = defaultConfigPath
|
|
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
|
|
|
|
// Create ServiceManager with profiles/ subdirectory
|
|
// This avoids modifying the global ConfigDirOverride for profile listing
|
|
profilesDir := filepath.Join(configDir, profilesSubdir)
|
|
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
|
|
|
|
return &ProfileManager{
|
|
configDir: configDir,
|
|
serviceMgr: serviceMgr,
|
|
}
|
|
}
|
|
|
|
// ListProfiles returns all available profiles
|
|
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
|
// Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive)
|
|
internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list profiles: %w", err)
|
|
}
|
|
|
|
// Convert internal profiles to Android Profile type
|
|
var profiles []*Profile
|
|
for _, p := range internalProfiles {
|
|
profiles = append(profiles, &Profile{
|
|
ID: p.ID.String(),
|
|
Name: p.Name,
|
|
Email: pm.profileEmail(p.ID.String()),
|
|
IsActive: p.IsActive,
|
|
})
|
|
}
|
|
|
|
return &ProfileArray{items: profiles}, nil
|
|
}
|
|
|
|
// GetActiveProfile returns the currently active profile name
|
|
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
|
// Use ServiceManager to stay consistent with ListProfiles
|
|
// ServiceManager uses active_profile.json
|
|
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get active profile: %w", err)
|
|
}
|
|
|
|
// ActiveProfileState only stores the ID (and username), not the display
|
|
// name. Resolve the ID to the full profile so callers get the real Name.
|
|
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
|
}
|
|
return &Profile{
|
|
ID: prof.ID.String(),
|
|
Name: prof.Name,
|
|
Email: pm.profileEmail(prof.ID.String()),
|
|
IsActive: true,
|
|
}, nil
|
|
}
|
|
|
|
// profileEmail returns the account email recorded for a profile. Display-only, so
|
|
// an unresolvable path degrades to "" rather than an error.
|
|
func (pm *ProfileManager) profileEmail(id string) string {
|
|
configPath, err := pm.getProfileConfigPath(id)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return readProfileEmail(configPath)
|
|
}
|
|
|
|
// SwitchProfile switches to a different profile
|
|
func (pm *ProfileManager) SwitchProfile(id string) error {
|
|
// Use ServiceManager to stay consistent with ListProfiles
|
|
// ServiceManager uses active_profile.json
|
|
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
|
ID: profilemanager.ID(id),
|
|
Username: androidUsername,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to switch profile: %w", err)
|
|
}
|
|
|
|
log.Infof("switched to profile: %s", id)
|
|
return nil
|
|
}
|
|
|
|
// AddProfile creates a new profile
|
|
func (pm *ProfileManager) AddProfile(profileName string) error {
|
|
// Use ServiceManager (creates profile in profiles/ directory)
|
|
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add profile: %w", err)
|
|
}
|
|
|
|
log.Infof("created new profile: %s", profile.ID)
|
|
return nil
|
|
}
|
|
|
|
// LogoutProfile logs out from a profile (clears authentication)
|
|
func (pm *ProfileManager) LogoutProfile(id string) error {
|
|
configPath, err := pm.getProfileConfigPath(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
|
return fmt.Errorf("id '%s' is not valid", id)
|
|
}
|
|
|
|
// Check if profile exists
|
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("profile '%s' does not exist", id)
|
|
}
|
|
|
|
// Read current config using internal profilemanager
|
|
config, err := profilemanager.ReadConfig(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read profile config: %w", err)
|
|
}
|
|
|
|
// Clear authentication by removing private key and SSH key
|
|
config.PrivateKey = ""
|
|
config.SSHKey = ""
|
|
|
|
// Save config using internal profilemanager
|
|
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
|
|
return fmt.Errorf("failed to save config: %w", err)
|
|
}
|
|
|
|
// Not fatal: a stale hint costs an account switch, not the logout itself.
|
|
if err := removeProfileEmail(configPath); err != nil {
|
|
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
|
|
}
|
|
|
|
log.Infof("logged out from profile: %s", id)
|
|
return nil
|
|
}
|
|
|
|
// RenameProfile changes a profile's display name. The profile ID, and therefore
|
|
// its on-disk filename, is left untouched: only the "name" field of the config
|
|
// is rewritten. This works for the default profile too, whose config lives in
|
|
// netbird.cfg rather than under profiles/.
|
|
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
|
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
|
|
return fmt.Errorf("failed to rename profile: %w", err)
|
|
}
|
|
|
|
log.Infof("renamed profile %s to: %s", id, newName)
|
|
return nil
|
|
}
|
|
|
|
// RemoveProfile deletes a profile
|
|
func (pm *ProfileManager) RemoveProfile(id string) error {
|
|
// Use ServiceManager (removes profile from profiles/ directory)
|
|
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
|
return fmt.Errorf("failed to remove profile: %w", err)
|
|
}
|
|
|
|
log.Infof("removed profile: %s", id)
|
|
return nil
|
|
}
|
|
|
|
// getProfileConfigPath returns the config file path for a profile
|
|
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
|
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
|
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
|
return "", fmt.Errorf("id %q is not valid", id)
|
|
}
|
|
|
|
if id == profilemanager.DefaultProfileName {
|
|
// Android uses netbird.cfg for default profile instead of default.json
|
|
// Default profile is stored in root configDir, not in profiles/
|
|
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
|
}
|
|
|
|
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
|
return filepath.Join(profilesDir, id+".json"), nil
|
|
}
|
|
|
|
// GetConfigPath returns the config file path for a given profile id
|
|
// Java should call this instead of constructing paths with Preferences.configFile()
|
|
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
|
return pm.getProfileConfigPath(id)
|
|
}
|
|
|
|
// GetStateFilePath returns the state file path for a given profile
|
|
// Java should call this instead of constructing paths with Preferences.stateFile()
|
|
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
|
if id == "" || id == profilemanager.DefaultProfileName {
|
|
return filepath.Join(pm.configDir, "state.json"), nil
|
|
}
|
|
|
|
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
|
return "", fmt.Errorf("id %q is not valid", id)
|
|
}
|
|
|
|
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
|
return filepath.Join(profilesDir, id+".state.json"), nil
|
|
}
|
|
|
|
// GetActiveConfigPath returns the config file path for the currently active profile
|
|
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile()
|
|
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
|
activeProfile, err := pm.GetActiveProfile()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get active profile: %w", err)
|
|
}
|
|
return pm.GetConfigPath(activeProfile.ID)
|
|
}
|
|
|
|
// GetActiveStateFilePath returns the state file path for the currently active profile
|
|
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile()
|
|
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
|
activeProfile, err := pm.GetActiveProfile()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get active profile: %w", err)
|
|
}
|
|
return pm.GetStateFilePath(activeProfile.ID)
|
|
}
|