[client] Extract SSH JWT cache into shared jwtcache package

Move the daemon's memguard-backed JWT token cache from client/server
into client/ssh/jwtcache so it can be reused outside the daemon
process. The TTL derivation from Config.SSHJWTCacheTTL (nil=default,
0=disabled) moves into jwtcache.ResolveTTL.

Wire the cache into the iOS SDK's SSH client: iOS has no daemon to
delegate caching to, so without it every reconnect forced the user
through the browser OAuth device-code flow. The cache lives on the
long-lived Client (the app creates a new SSHClient per session) and
uses the same config-driven TTL semantics as the daemon.
This commit is contained in:
Zoltán Papp
2026-08-02 10:04:12 +02:00
parent 90bb5e7c0f
commit 081f2d153b
5 changed files with 137 additions and 97 deletions
+7
View File
@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/ssh/jwtcache"
"github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/route"
@@ -87,6 +88,11 @@ type Client struct {
// config holds the active configuration once Run has loaded it. Consumed by // 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. // the in-app SSH client for the NetBird SSH key and the OAuth flow.
config *profilemanager.Config 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 // NewClient instantiate a new Client
@@ -103,6 +109,7 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
ctxCancelLock: &sync.Mutex{}, ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener, networkChangeListener: networkChangeListener,
dnsManager: dnsManager, dnsManager: dnsManager,
sshJWTCache: jwtcache.New(),
} }
} }
+13
View File
@@ -20,6 +20,7 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/profilemanager"
nbssh "github.com/netbirdio/netbird/client/ssh" nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/client/ssh/detection"
"github.com/netbirdio/netbird/client/ssh/jwtcache"
) )
const ( const (
@@ -293,6 +294,14 @@ func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engin
} }
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) { 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() s.mu.Lock()
urlOpener := s.urlOpener urlOpener := s.urlOpener
s.mu.Unlock() s.mu.Unlock()
@@ -324,6 +333,10 @@ func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error)
if token == "" { if token == "" {
return "", errors.New("empty token returned by IdP") 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 return token, nil
} }
-79
View File
@@ -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{}
}
+8 -18
View File
@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/ssh/jwtcache"
"github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/client/system"
mgm "github.com/netbirdio/netbird/shared/management/client" mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/domain"
@@ -50,9 +51,6 @@ const (
defaultMaxRetryTime = 14 * 24 * time.Hour defaultMaxRetryTime = 14 * 24 * time.Hour
defaultRetryMultiplier = 1.7 defaultRetryMultiplier = 1.7
// JWT token cache TTL for the client daemon (disabled by default)
defaultJWTCacheTTL = 0
errRestoreResidualState = "failed to restore residual state: %v" errRestoreResidualState = "failed to restore residual state: %v"
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" 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" errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
@@ -134,7 +132,7 @@ type Server struct {
updateManager *updater.Manager updateManager *updater.Manager
jwtCache *jwtCache jwtCache *jwtcache.Cache
} }
type oauthAuthFlow struct { type oauthAuthFlow struct {
@@ -156,7 +154,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
updateSettingsDisabled: updateSettingsDisabled, updateSettingsDisabled: updateSettingsDisabled,
captureEnabled: captureEnabled, captureEnabled: captureEnabled,
networksDisabled: networksDisabled, networksDisabled: networksDisabled,
jwtCache: newJWTCache(), jwtCache: jwtcache.New(),
extendAuthSessionFlow: auth.NewPendingFlow(), extendAuthSessionFlow: auth.NewPendingFlow(),
probeThrottle: newProbeThrottle(probeThreshold), probeThrottle: newProbeThrottle(probeThreshold),
} }
@@ -1624,19 +1622,11 @@ func (s *Server) getJWTCacheTTL() time.Duration {
config := s.config config := s.config
s.mutex.Unlock() s.mutex.Unlock()
if config == nil || config.SSHJWTCacheTTL == nil { if config == nil {
return defaultJWTCacheTTL return jwtcache.DefaultTTL
} }
seconds := *config.SSHJWTCacheTTL return jwtcache.ResolveTTL(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
} }
// RequestJWTAuth initiates JWT authentication flow for SSH // RequestJWTAuth initiates JWT authentication flow for SSH
@@ -1658,7 +1648,7 @@ func (s *Server) RequestJWTAuth(
jwtCacheTTL := s.getJWTCacheTTL() jwtCacheTTL := s.getJWTCacheTTL()
if jwtCacheTTL > 0 { 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") log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
return &proto.RequestJWTAuthResponse{ return &proto.RequestJWTAuthResponse{
@@ -1731,7 +1721,7 @@ func (s *Server) WaitJWTToken(
jwtCacheTTL := s.getJWTCacheTTL() jwtCacheTTL := s.getJWTCacheTTL()
if jwtCacheTTL > 0 { if jwtCacheTTL > 0 {
s.jwtCache.store(token, jwtCacheTTL) s.jwtCache.Store(token, jwtCacheTTL)
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL) log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
} else { } else {
log.Debug("JWT caching disabled, not storing token") log.Debug("JWT caching disabled, not storing token")
+109
View File
@@ -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
}