[android] Deduplicate the OAuth token flow and fix the SSH login hint

Extract the shared RequestAuthInfo -> Open -> WaitToken sequence from the
login flow and the SSH JWT flow into runOAuthFlow. Open is now called
synchronously by both flows, matching iOS; openers must post their UI
work instead of blocking, which the app-side openers already do.

The SSH flow read its login hint via profilemanager.GetLoginHint, which
resolves desktop-layout files that the Android app never writes, so the
hint was always empty and the device-code flow could prompt for account
selection. Both flows now read the hint from the profile account file
via the config path, taken from authSnapshot so a concurrent profile
switch cannot pair one profile's config with another's hint.
This commit is contained in:
Zoltan Papp
2026-08-14 22:31:54 +02:00
parent 78c95bb8ec
commit c28cf2fa61
2 changed files with 46 additions and 35 deletions

View File

@@ -204,26 +204,48 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
// leaves the choice to the IdP, which is how accounts get switched.
if a.cfgPath != "" {
if hint := readProfileEmail(a.cfgPath); hint != "" {
if setter, ok := oAuthFlow.(loginHintSetter); ok {
setter.SetLoginHint(hint)
}
return runOAuthFlow(a.ctx, oAuthFlow, profileLoginHint(a.cfgPath), urlOpener, nil)
}
// profileLoginHint returns the stored account email for the profile at cfgPath.
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
// leaves the choice to the IdP, which is how accounts get switched.
func profileLoginHint(cfgPath string) string {
if cfgPath == "" {
return ""
}
return readProfileEmail(cfgPath)
}
// runOAuthFlow drives an already acquired OAuth flow to a token: applies the
// login hint, 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, hint string, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) {
if hint != "" {
if setter, ok := flow.(loginHintSetter); ok {
setter.SetLoginHint(hint)
}
}
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
flowInfo, err := flow.RequestAuthInfo(ctx)
if err != nil {
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
return nil, fmt.Errorf("request auth info: %w", err)
}
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo)
if onWaiting != nil {
onWaiting()
}
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
if err != nil {
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
return nil, fmt.Errorf("wait for token: %w", err)
}
return &tokenInfo, nil

View File

@@ -165,7 +165,7 @@ func (s *SSHClient) Connect(host string, port int, user, password string) error
return fmt.Errorf("invalid port: %d", port)
}
cfg, _, cc := s.nb.stateSnapshot()
cfg, cfgPath, cc := s.nb.authSnapshot()
if cc == nil {
return errors.New("netbird client not running")
}
@@ -185,7 +185,7 @@ func (s *SSHClient) Connect(host string, port int, user, password string) error
serverType := detectServerType(host, port)
log.Debugf("SSH server type: %s", serverType)
authMethods, hostKeyCallback, err := s.buildAuth(cfg, engine, serverType, password)
authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password)
if err != nil {
return err
}
@@ -345,12 +345,12 @@ func (s *SSHClient) startSession(cols, rows int) error {
return nil
}
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engine,
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine,
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
switch serverType {
case detection.ServerTypeNetBirdJWT:
token, err := s.requestJWTToken(cfg)
token, err := s.requestJWTToken(cfg, cfgPath)
if err != nil {
return nil, nil, fmt.Errorf("jwt: %w", err)
}
@@ -465,7 +465,7 @@ func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) {
}, nil
}
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) {
s.mu.Lock()
urlOpener := s.urlOpener
s.mu.Unlock()
@@ -476,29 +476,18 @@ func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profilemanager.GetLoginHint())
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, "")
if err != nil {
return "", fmt.Errorf("create oauth flow: %w", err)
}
flowInfo, err := flow.RequestAuthInfo(ctx)
// The status callback covers the browser round-trip, which would
// otherwise leave the terminal blank.
tokenInfo, err := runOAuthFlow(ctx, flow, profileLoginHint(cfgPath), urlOpener, func() {
s.notifyStatus("Waiting for browser authentication...")
})
if err != nil {
return "", fmt.Errorf("request auth info: %w", err)
}
// Called synchronously: Open is what marks the surface as opened on the
// client side, and OnLoginSuccess below is a no-op until it has. Starting
// both in their own goroutines let them race, so a fast token left the
// browser in front of the terminal.
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
// WaitToken blocks for as long as the browser round-trip takes, so say so
// rather than leaving the terminal blank.
s.notifyStatus("Waiting for browser authentication...")
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
if err != nil {
return "", fmt.Errorf("wait for token: %w", err)
return "", err
}
token := tokenInfo.GetTokenToUse()