diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 99bd5a392..d51e1dbcf 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/ssh/jwtcache" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -87,6 +88,11 @@ type Client struct { // config holds the active configuration once Run has loaded it. Consumed by // the in-app SSH client for the NetBird SSH key and the OAuth flow. config *profilemanager.Config + + // sshJWTCache keeps the SSH JWT token between reconnects so the user is not + // forced through the browser OAuth flow on every session. Lives on Client + // (not SSHClient) because the app creates a new SSHClient per session. + sshJWTCache *jwtcache.Cache } // NewClient instantiate a new Client @@ -103,6 +109,7 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, dnsManager: dnsManager, + sshJWTCache: jwtcache.New(), } } diff --git a/client/ios/NetBirdSDK/ssh_client.go b/client/ios/NetBirdSDK/ssh_client.go index b0a4e40eb..d4e520be9 100644 --- a/client/ios/NetBirdSDK/ssh_client.go +++ b/client/ios/NetBirdSDK/ssh_client.go @@ -20,6 +20,7 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/ssh/detection" + "github.com/netbirdio/netbird/client/ssh/jwtcache" ) const ( @@ -293,6 +294,14 @@ func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engin } func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) { + // Reuse a cached token so the user is not forced through the browser OAuth + // flow on every reconnect. TTL comes from cfg.SSHJWTCacheTTL, same as the + // daemon's cache; unset/0 disables caching. + if token, ok := s.nb.sshJWTCache.Get(); ok { + log.Debug("SSH: reusing cached JWT token") + return token, nil + } + s.mu.Lock() urlOpener := s.urlOpener s.mu.Unlock() @@ -324,6 +333,10 @@ func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) if token == "" { return "", errors.New("empty token returned by IdP") } + + if ttl := jwtcache.ResolveTTL(cfg.SSHJWTCacheTTL); ttl > 0 { + s.nb.sshJWTCache.Store(token, ttl) + } return token, nil } diff --git a/client/server/jwt_cache.go b/client/server/jwt_cache.go deleted file mode 100644 index 21e170517..000000000 --- a/client/server/jwt_cache.go +++ /dev/null @@ -1,79 +0,0 @@ -package server - -import ( - "sync" - "time" - - "github.com/awnumar/memguard" - log "github.com/sirupsen/logrus" -) - -type jwtCache struct { - mu sync.RWMutex - enclave *memguard.Enclave - expiresAt time.Time - timer *time.Timer - maxTokenSize int -} - -func newJWTCache() *jwtCache { - return &jwtCache{ - maxTokenSize: 8192, - } -} - -func (c *jwtCache) store(token string, maxAge time.Duration) { - c.mu.Lock() - defer c.mu.Unlock() - - c.cleanup() - - if c.timer != nil { - c.timer.Stop() - } - - tokenBytes := []byte(token) - c.enclave = memguard.NewEnclave(tokenBytes) - - c.expiresAt = time.Now().Add(maxAge) - - var timer *time.Timer - timer = time.AfterFunc(maxAge, func() { - c.mu.Lock() - defer c.mu.Unlock() - if c.timer != timer { - return - } - c.cleanup() - c.timer = nil - log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge) - }) - c.timer = timer -} - -func (c *jwtCache) get() (string, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - - if c.enclave == nil || time.Now().After(c.expiresAt) { - return "", false - } - - buffer, err := c.enclave.Open() - if err != nil { - log.Debugf("Failed to open JWT token enclave: %v", err) - return "", false - } - defer buffer.Destroy() - - token := string(buffer.Bytes()) - return token, true -} - -// cleanup destroys the secure enclave, must be called with lock held -func (c *jwtCache) cleanup() { - if c.enclave != nil { - c.enclave = nil - } - c.expiresAt = time.Time{} -} diff --git a/client/server/server.go b/client/server/server.go index aaab5cc02..c1f23ff8c 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -26,6 +26,7 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/ssh/jwtcache" "github.com/netbirdio/netbird/client/system" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" @@ -50,9 +51,6 @@ const ( defaultMaxRetryTime = 14 * 24 * time.Hour defaultRetryMultiplier = 1.7 - // JWT token cache TTL for the client daemon (disabled by default) - defaultJWTCacheTTL = 0 - errRestoreResidualState = "failed to restore residual state: %v" errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled" @@ -134,7 +132,7 @@ type Server struct { updateManager *updater.Manager - jwtCache *jwtCache + jwtCache *jwtcache.Cache } type oauthAuthFlow struct { @@ -156,7 +154,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable updateSettingsDisabled: updateSettingsDisabled, captureEnabled: captureEnabled, networksDisabled: networksDisabled, - jwtCache: newJWTCache(), + jwtCache: jwtcache.New(), extendAuthSessionFlow: auth.NewPendingFlow(), probeThrottle: newProbeThrottle(probeThreshold), } @@ -1624,19 +1622,11 @@ func (s *Server) getJWTCacheTTL() time.Duration { config := s.config s.mutex.Unlock() - if config == nil || config.SSHJWTCacheTTL == nil { - return defaultJWTCacheTTL + if config == nil { + return jwtcache.DefaultTTL } - seconds := *config.SSHJWTCacheTTL - if seconds == 0 { - log.Debug("SSH JWT cache disabled (configured to 0)") - return 0 - } - - ttl := time.Duration(seconds) * time.Second - log.Debugf("SSH JWT cache TTL set to %v from config", ttl) - return ttl + return jwtcache.ResolveTTL(config.SSHJWTCacheTTL) } // RequestJWTAuth initiates JWT authentication flow for SSH @@ -1658,7 +1648,7 @@ func (s *Server) RequestJWTAuth( jwtCacheTTL := s.getJWTCacheTTL() if jwtCacheTTL > 0 { - if cachedToken, found := s.jwtCache.get(); found { + if cachedToken, found := s.jwtCache.Get(); found { log.Debugf("JWT token found in cache, returning cached token for SSH authentication") return &proto.RequestJWTAuthResponse{ @@ -1731,7 +1721,7 @@ func (s *Server) WaitJWTToken( jwtCacheTTL := s.getJWTCacheTTL() if jwtCacheTTL > 0 { - s.jwtCache.store(token, jwtCacheTTL) + s.jwtCache.Store(token, jwtCacheTTL) log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL) } else { log.Debug("JWT caching disabled, not storing token") diff --git a/client/ssh/jwtcache/cache.go b/client/ssh/jwtcache/cache.go new file mode 100644 index 000000000..a2c4df85c --- /dev/null +++ b/client/ssh/jwtcache/cache.go @@ -0,0 +1,109 @@ +// Package jwtcache provides an in-memory, TTL-bound cache for SSH JWT tokens. +// The token is kept in a secure memguard enclave and wiped from memory when it +// expires. It is shared by the daemon gRPC server and the mobile SDKs, which +// have no daemon process to delegate caching to. +package jwtcache + +import ( + "sync" + "time" + + "github.com/awnumar/memguard" + log "github.com/sirupsen/logrus" +) + +// DefaultTTL is used when no TTL is configured: caching disabled. +const DefaultTTL = 0 + +// Cache stores a single JWT token in a secure enclave until it expires. +type Cache struct { + mu sync.RWMutex + enclave *memguard.Enclave + expiresAt time.Time + timer *time.Timer + maxTokenSize int +} + +// New creates an empty Cache. +func New() *Cache { + return &Cache{ + maxTokenSize: 8192, + } +} + +// Store caches the token for maxAge. A previously stored token is wiped. +func (c *Cache) Store(token string, maxAge time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.cleanup() + + if c.timer != nil { + c.timer.Stop() + } + + tokenBytes := []byte(token) + c.enclave = memguard.NewEnclave(tokenBytes) + + c.expiresAt = time.Now().Add(maxAge) + + var timer *time.Timer + timer = time.AfterFunc(maxAge, func() { + c.mu.Lock() + defer c.mu.Unlock() + if c.timer != timer { + return + } + c.cleanup() + c.timer = nil + log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge) + }) + c.timer = timer +} + +// Get returns the cached token, or false if none is stored or it has expired. +func (c *Cache) Get() (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.enclave == nil || time.Now().After(c.expiresAt) { + return "", false + } + + buffer, err := c.enclave.Open() + if err != nil { + log.Debugf("Failed to open JWT token enclave: %v", err) + return "", false + } + defer buffer.Destroy() + + token := string(buffer.Bytes()) + return token, true +} + +// cleanup destroys the secure enclave, must be called with lock held +func (c *Cache) cleanup() { + if c.enclave != nil { + c.enclave = nil + } + c.expiresAt = time.Time{} +} + +// ResolveTTL converts the configured TTL (seconds, from +// profilemanager.Config.SSHJWTCacheTTL) into a duration. Returns DefaultTTL +// when unset; 0 means caching is disabled. +func ResolveTTL(configuredSeconds *int) time.Duration { + if configuredSeconds == nil { + return DefaultTTL + } + + seconds := *configuredSeconds + if seconds == 0 { + log.Debug("SSH JWT cache disabled (configured to 0)") + return 0 + } + + ttl := time.Duration(seconds) * time.Second + log.Debugf("SSH JWT cache TTL set to %v from config", ttl) + return ttl +}