mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-14 02:39:06 +02:00
Merge branch 'main' into file-share
This commit is contained in:
@@ -6,11 +6,21 @@ import (
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
type jwtCache struct {
|
||||
mu sync.RWMutex
|
||||
enclave *memguard.Enclave
|
||||
mu sync.RWMutex
|
||||
enclave *memguard.Enclave
|
||||
owner *ipcauth.Identity
|
||||
|
||||
// generation counts the invalidations. A caller that starts an
|
||||
// authentication takes the generation first and hands it back to store, so
|
||||
// a token obtained under a session that ended while the IdP was being
|
||||
// polled cannot land in the cache the new session is using.
|
||||
generation uint64
|
||||
|
||||
expiresAt time.Time
|
||||
timer *time.Timer
|
||||
maxTokenSize int
|
||||
@@ -22,10 +32,23 @@ func newJWTCache() *jwtCache {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *jwtCache) store(token string, maxAge time.Duration) {
|
||||
func (c *jwtCache) currentGeneration() uint64 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return c.generation
|
||||
}
|
||||
|
||||
// store keeps the token only while generation is still the current one, and
|
||||
// reports whether it did. See the generation field.
|
||||
func (c *jwtCache) store(token string, owner ipcauth.Identity, maxAge time.Duration, generation uint64) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.generation != generation {
|
||||
return false
|
||||
}
|
||||
|
||||
c.cleanup()
|
||||
|
||||
if c.timer != nil {
|
||||
@@ -35,6 +58,7 @@ func (c *jwtCache) store(token string, maxAge time.Duration) {
|
||||
tokenBytes := []byte(token)
|
||||
c.enclave = memguard.NewEnclave(tokenBytes)
|
||||
|
||||
c.owner = &owner
|
||||
c.expiresAt = time.Now().Add(maxAge)
|
||||
|
||||
var timer *time.Timer
|
||||
@@ -49,9 +73,12 @@ func (c *jwtCache) store(token string, maxAge time.Duration) {
|
||||
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
||||
})
|
||||
c.timer = timer
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *jwtCache) get() (string, bool) {
|
||||
// get returns the cached token to the identity that stored it.
|
||||
func (c *jwtCache) get(caller ipcauth.Identity) (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -59,6 +86,11 @@ func (c *jwtCache) get() (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if c.owner == nil || !c.owner.SameUser(caller) {
|
||||
log.Warnf("refusing the cached SSH JWT: caller %s is not the identity that obtained it", caller)
|
||||
return "", false
|
||||
}
|
||||
|
||||
buffer, err := c.enclave.Open()
|
||||
if err != nil {
|
||||
log.Debugf("Failed to open JWT token enclave: %v", err)
|
||||
@@ -70,10 +102,23 @@ func (c *jwtCache) get() (string, bool) {
|
||||
return token, true
|
||||
}
|
||||
|
||||
func (c *jwtCache) clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
c.timer = nil
|
||||
}
|
||||
c.cleanup()
|
||||
c.generation++
|
||||
}
|
||||
|
||||
// cleanup destroys the secure enclave, must be called with lock held
|
||||
func (c *jwtCache) cleanup() {
|
||||
if c.enclave != nil {
|
||||
c.enclave = nil
|
||||
}
|
||||
c.owner = nil
|
||||
c.expiresAt = time.Time{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
const testTTL = time.Minute
|
||||
|
||||
func unixCaller(uid uint32) ipcauth.Identity {
|
||||
return ipcauth.Identity{UID: uid, GID: uid}
|
||||
}
|
||||
|
||||
func windowsCaller(sid string) ipcauth.Identity {
|
||||
return ipcauth.Identity{SID: sid}
|
||||
}
|
||||
|
||||
func TestJWTCache_ServesTheOwner(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
owner := unixCaller(1000)
|
||||
c.store("token-for-1000", owner, testTTL, c.currentGeneration())
|
||||
|
||||
got, found := c.get(owner)
|
||||
|
||||
require.True(t, found, "the identity that stored the token must get it back")
|
||||
assert.Equal(t, "token-for-1000", got)
|
||||
}
|
||||
|
||||
// The disclosure this cache guards against: one local account collecting the
|
||||
// SSH JWT another account's authentication put in the daemon-wide cache.
|
||||
func TestJWTCache_RefusesAnotherLocalUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
owner ipcauth.Identity
|
||||
caller ipcauth.Identity
|
||||
}{
|
||||
{"different uid", unixCaller(1000), unixCaller(65534)},
|
||||
{"root is not the owner either", unixCaller(1000), unixCaller(0)},
|
||||
{"different sid", windowsCaller("S-1-5-21-1-2-3-1001"), windowsCaller("S-1-5-21-1-2-3-1002")},
|
||||
{"windows caller against a unix owner", unixCaller(0), windowsCaller("S-1-5-18")},
|
||||
{"unix caller against a windows owner", windowsCaller("S-1-5-18"), unixCaller(0)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
c.store("victim-token", tt.owner, testTTL, c.currentGeneration())
|
||||
|
||||
got, found := c.get(tt.caller)
|
||||
|
||||
assert.False(t, found, "a caller that is not the owner must get a miss")
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTCache_EmptyCacheMatchesNobody(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
|
||||
got, found := c.get(unixCaller(0))
|
||||
|
||||
assert.False(t, found)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
// An entry with no recorded owner must match nobody, root included: an
|
||||
// unidentified caller arrives as the zero Identity, which carries uid 0. This
|
||||
// pins the nil-owner guard rather than the comparison, so it sets up an entry
|
||||
// that exists and then drops its owner.
|
||||
func TestJWTCache_UnownedEntryMatchesNobody(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
c.store("token", unixCaller(1000), testTTL, c.currentGeneration())
|
||||
c.owner = nil
|
||||
|
||||
got, found := c.get(unixCaller(0))
|
||||
|
||||
assert.False(t, found)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
// The same user calling once elevated and once not is still the same user, so
|
||||
// hiding their own token from them would be wrong.
|
||||
func TestJWTCache_ElevationDoesNotChangeTheOwner(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
sid := "S-1-5-21-1-2-3-1001"
|
||||
owner := windowsCaller(sid)
|
||||
owner.Elevated = true
|
||||
c.store("token", owner, testTTL, c.currentGeneration())
|
||||
|
||||
got, found := c.get(windowsCaller(sid))
|
||||
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "token", got)
|
||||
}
|
||||
|
||||
func TestJWTCache_Expiry(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
owner := unixCaller(1000)
|
||||
c.store("token", owner, testTTL, c.currentGeneration())
|
||||
c.expiresAt = time.Now().Add(-time.Second)
|
||||
|
||||
_, found := c.get(owner)
|
||||
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
// Logout and SwitchProfile call clear — Down deliberately does not: the NetBird
|
||||
// session the token speaks for is over, so not even its owner may have it back.
|
||||
func TestJWTCache_ClearDropsTheEntry(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
owner := unixCaller(1000)
|
||||
c.store("token", owner, testTTL, c.currentGeneration())
|
||||
|
||||
c.clear()
|
||||
|
||||
_, found := c.get(owner)
|
||||
assert.False(t, found)
|
||||
assert.Nil(t, c.owner, "clear must forget the owner too")
|
||||
assert.Nil(t, c.timer, "clear must stop the expiry timer")
|
||||
}
|
||||
|
||||
// WaitJWTToken polls the IdP unlocked, so a logout or a profile switch can
|
||||
// clear the cache while a flow is still in the air. The token that flow returns
|
||||
// belongs to the session that ended, so it must not land in the cache the new
|
||||
// session is using.
|
||||
func TestJWTCache_StoreFromAnEndedSessionIsDropped(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
owner := unixCaller(1000)
|
||||
|
||||
// The generation a caller takes when its authentication starts.
|
||||
generation := c.currentGeneration()
|
||||
|
||||
c.clear() // logout or profile switch, while the IdP is still being polled
|
||||
|
||||
stored := c.store("stale-token", owner, testTTL, generation)
|
||||
|
||||
assert.False(t, stored, "a token from an ended session must not be cached")
|
||||
_, found := c.get(owner)
|
||||
assert.False(t, found, "the cache must stay empty after the session ended")
|
||||
}
|
||||
|
||||
// The same caller must still be able to store once it re-reads the generation, so
|
||||
// the guard does not wedge the cache after any invalidation.
|
||||
func TestJWTCache_StoreWorksAgainAfterClear(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
owner := unixCaller(1000)
|
||||
|
||||
c.clear()
|
||||
|
||||
require.True(t, c.store("token", owner, testTTL, c.currentGeneration()))
|
||||
|
||||
got, found := c.get(owner)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "token", got)
|
||||
}
|
||||
|
||||
func TestJWTCache_StoreReplacesThePreviousOwner(t *testing.T) {
|
||||
c := newJWTCache()
|
||||
first := unixCaller(1000)
|
||||
second := unixCaller(1001)
|
||||
|
||||
c.store("first-token", first, testTTL, c.currentGeneration())
|
||||
c.store("second-token", second, testTTL, c.currentGeneration())
|
||||
|
||||
_, found := c.get(first)
|
||||
assert.False(t, found, "the previous owner must not reach the new token")
|
||||
|
||||
got, found := c.get(second)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "second-token", got)
|
||||
}
|
||||
+57
-8
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/localmetrics"
|
||||
@@ -153,9 +154,17 @@ type Server struct {
|
||||
}
|
||||
|
||||
type oauthAuthFlow struct {
|
||||
expiresAt time.Time
|
||||
flow auth.OAuthFlow
|
||||
info auth.AuthFlowInfo
|
||||
expiresAt time.Time
|
||||
flow auth.OAuthFlow
|
||||
info auth.AuthFlowInfo
|
||||
|
||||
// cacheGeneration is the SSH JWT cache's generation as of the start of the
|
||||
// request that created this flow. The flow outlives a profile switch, so
|
||||
// reading the generation any later — when the IdP has answered, or when the
|
||||
// token finally arrives — would read the new session's one and let the old
|
||||
// session's token into the new session's cache.
|
||||
cacheGeneration uint64
|
||||
|
||||
waitCancel context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -656,6 +665,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
status := state.CurrentStatus()
|
||||
if status == internal.StatusConnected {
|
||||
return &proto.LoginResponse{}, nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
status, err := state.Status()
|
||||
if err != nil || (status != internal.StatusNeedsLogin && status != internal.StatusLoginFailed) {
|
||||
@@ -1250,6 +1264,8 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
s.config = config
|
||||
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
|
||||
|
||||
s.jwtCache.clear()
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
s.publishProfileListChanged(*msg.ProfileName)
|
||||
}
|
||||
@@ -1420,6 +1436,7 @@ func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string
|
||||
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
|
||||
log.Errorf("failed to cleanup connection: %v", err)
|
||||
}
|
||||
s.jwtCache.clear()
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
}
|
||||
@@ -1448,6 +1465,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe
|
||||
log.Errorf("failed to cleanup connection: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
s.jwtCache.clear()
|
||||
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
@@ -1779,6 +1797,20 @@ func (s *Server) getJWTCacheTTL() time.Duration {
|
||||
return ttl
|
||||
}
|
||||
|
||||
// cachedJWT returns the cached SSH JWT to the identity that obtained it, and a
|
||||
// miss on a control channel that carries no caller identity.
|
||||
func (s *Server) cachedJWT(ctx context.Context) (string, bool) {
|
||||
caller, ok := ipcauth.CallerIdentity(ctx)
|
||||
if !ok {
|
||||
// Expected and handled on a control channel with no peer identity: the
|
||||
// caller re-authenticates. daemonServerOptions warns about it once at
|
||||
// startup, so this stays out of the per-request log.
|
||||
log.Debug("not serving the cached SSH JWT: the caller's identity cannot be verified on this control channel")
|
||||
return "", false
|
||||
}
|
||||
return s.jwtCache.get(caller)
|
||||
}
|
||||
|
||||
// RequestJWTAuth initiates JWT authentication flow for SSH
|
||||
func (s *Server) RequestJWTAuth(
|
||||
ctx context.Context,
|
||||
@@ -1788,8 +1820,14 @@ func (s *Server) RequestJWTAuth(
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// The generation is read here, with the config and under the same lock, not
|
||||
// where the flow is stored below: RequestAuthInfo talks to the IdP in
|
||||
// between, and a switch or a logout during that call would otherwise be
|
||||
// read as the generation this flow belongs to. SwitchProfile holds
|
||||
// s.mutex across its own clear(), so the pair cannot be torn.
|
||||
s.mutex.Lock()
|
||||
config := s.config
|
||||
cacheGeneration := s.jwtCache.currentGeneration()
|
||||
s.mutex.Unlock()
|
||||
|
||||
if config == nil {
|
||||
@@ -1798,7 +1836,7 @@ func (s *Server) RequestJWTAuth(
|
||||
|
||||
jwtCacheTTL := s.getJWTCacheTTL()
|
||||
if jwtCacheTTL > 0 {
|
||||
if cachedToken, found := s.jwtCache.get(); found {
|
||||
if cachedToken, found := s.cachedJWT(ctx); found {
|
||||
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
|
||||
|
||||
return &proto.RequestJWTAuthResponse{
|
||||
@@ -1832,6 +1870,7 @@ func (s *Server) RequestJWTAuth(
|
||||
s.oauthAuthFlow.flow = oAuthFlow
|
||||
s.oauthAuthFlow.info = authInfo
|
||||
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
|
||||
s.oauthAuthFlow.cacheGeneration = cacheGeneration
|
||||
s.mutex.Unlock()
|
||||
|
||||
return &proto.RequestJWTAuthResponse{
|
||||
@@ -1856,6 +1895,10 @@ func (s *Server) WaitJWTToken(
|
||||
s.mutex.Lock()
|
||||
oAuthFlow := s.oauthAuthFlow.flow
|
||||
authInfo := s.oauthAuthFlow.info
|
||||
// Recorded when the flow was created, not read here: the flow survives a
|
||||
// profile switch, and everything from RequestJWTAuth to the IdP answering
|
||||
// has to count as the same session for the cache.
|
||||
generation := s.oauthAuthFlow.cacheGeneration
|
||||
s.mutex.Unlock()
|
||||
|
||||
if oAuthFlow == nil || authInfo.DeviceCode != req.DeviceCode {
|
||||
@@ -1870,11 +1913,17 @@ func (s *Server) WaitJWTToken(
|
||||
token := tokenInfo.GetTokenToUse()
|
||||
|
||||
jwtCacheTTL := s.getJWTCacheTTL()
|
||||
if jwtCacheTTL > 0 {
|
||||
s.jwtCache.store(token, jwtCacheTTL)
|
||||
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
|
||||
} else {
|
||||
switch caller, ok := ipcauth.CallerIdentity(ctx); {
|
||||
case jwtCacheTTL <= 0:
|
||||
log.Debug("JWT caching disabled, not storing token")
|
||||
case !ok:
|
||||
log.Debug("not caching the SSH JWT: the caller's identity cannot be verified on this control channel")
|
||||
default:
|
||||
if s.jwtCache.store(token, caller, jwtCacheTTL, generation) {
|
||||
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
|
||||
} else {
|
||||
log.Debug("not caching the SSH JWT: the session it was obtained under ended while the IdP was polled")
|
||||
}
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
|
||||
@@ -18,6 +18,10 @@ func newTestServer() *Server {
|
||||
return &Server{
|
||||
rootCtx: context.Background(),
|
||||
statusRecorder: peer.NewRecorder(""),
|
||||
// New always populates the SSH JWT cache and the logout and
|
||||
// profile-switch paths call into it unconditionally, so a Server
|
||||
// assembled field by field has to populate it too.
|
||||
jwtCache: newJWTCache(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/localmetrics"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// These cover the RPC side of the cache: the cache itself is exercised in
|
||||
// jwt_cache_test.go, but a correct cache buys nothing if the handlers around it
|
||||
// consult the wrong identity or forget to clear it.
|
||||
|
||||
func TestCachedJWT_ServesTheOwner(t *testing.T) {
|
||||
s := newTestServer()
|
||||
owner := unprivilegedIdentity()
|
||||
s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration())
|
||||
|
||||
got, found := s.cachedJWT(ctxWithIdentity(owner))
|
||||
|
||||
require.True(t, found, "the identity that obtained the token must get it back")
|
||||
assert.Equal(t, "token", got)
|
||||
}
|
||||
|
||||
func TestCachedJWT_RefusesAnotherCaller(t *testing.T) {
|
||||
s := newTestServer()
|
||||
s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration())
|
||||
|
||||
got, found := s.cachedJWT(ctxWithIdentity(privilegedIdentity()))
|
||||
|
||||
assert.False(t, found, "a caller that did not obtain the token must get a miss")
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
// A control channel that carries no caller identity — a TCP daemon socket, or a
|
||||
// platform with no peer-credential primitive — cannot tell one local user from
|
||||
// another, so cachedJWT must fail closed there.
|
||||
func TestCachedJWT_WithoutCallerIdentity(t *testing.T) {
|
||||
s := newTestServer()
|
||||
s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration())
|
||||
|
||||
got, found := s.cachedJWT(context.Background())
|
||||
|
||||
assert.False(t, found)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
// profileFixture points the profile globals at a temp dir holding a single
|
||||
// default profile, which is the one ActiveProfileState.FilePath resolves
|
||||
// without consulting the current OS user.
|
||||
func profileFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
defaultConfig := filepath.Join(dir, "default.json")
|
||||
require.NoError(t, os.WriteFile(defaultConfig, []byte("{}"), 0o600))
|
||||
|
||||
origDir := profilemanager.DefaultConfigPathDir
|
||||
origDefault := profilemanager.DefaultConfigPath
|
||||
origState := profilemanager.ActiveProfileStatePath
|
||||
origOverride := profilemanager.ConfigDirOverride
|
||||
|
||||
profilemanager.DefaultConfigPathDir = dir
|
||||
profilemanager.DefaultConfigPath = defaultConfig
|
||||
profilemanager.ActiveProfileStatePath = filepath.Join(dir, "active_profile.json")
|
||||
profilemanager.ConfigDirOverride = dir
|
||||
|
||||
t.Cleanup(func() {
|
||||
profilemanager.DefaultConfigPathDir = origDir
|
||||
profilemanager.DefaultConfigPath = origDefault
|
||||
profilemanager.ActiveProfileStatePath = origState
|
||||
profilemanager.ConfigDirOverride = origOverride
|
||||
})
|
||||
|
||||
return defaultConfig
|
||||
}
|
||||
|
||||
// A profile carries its own NetBird account, so a token obtained under the
|
||||
// previous one must not survive the switch even for the local user who
|
||||
// obtained it.
|
||||
func TestSwitchProfile_ClearsJWTCache(t *testing.T) {
|
||||
defaultConfig := profileFixture(t)
|
||||
|
||||
// localmetrics.NewManager runs until its context is done, so the manager
|
||||
// must not outlive the test.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
s := newTestServer()
|
||||
s.profileManager = profilemanager.NewServiceManager(defaultConfig)
|
||||
s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, nil)
|
||||
|
||||
// A second profile to move to, so the request goes through
|
||||
// switchProfileIfNeeded rather than the no-op path a nil request takes.
|
||||
const target = "second"
|
||||
username := "tester"
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
owner := unprivilegedIdentity()
|
||||
s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration())
|
||||
|
||||
name := target
|
||||
_, err = s.SwitchProfile(ctx, &proto.SwitchProfileRequest{ProfileName: &name, Username: &username})
|
||||
require.NoError(t, err)
|
||||
|
||||
active, err := s.profileManager.GetActiveProfileState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, profilemanager.ID(target), active.ID, "the profile must actually have changed")
|
||||
|
||||
_, found := s.jwtCache.get(owner)
|
||||
assert.False(t, found, "switching profile must drop the cached SSH JWT")
|
||||
}
|
||||
|
||||
// Down ends the connection, not the session: the peer stays enrolled and the
|
||||
// token still belongs to the same NetBird identity, so `down` followed by `up`
|
||||
// must not cost the owner a fresh device-code flow.
|
||||
//
|
||||
// The logout handlers do call cleanupConnection, and SwitchProfile does not;
|
||||
// what they have in common is that each clears the cache itself, right after,
|
||||
// so tearing the connection down is no longer what decides the token's fate.
|
||||
func TestCleanupConnection_KeepsJWTCache(t *testing.T) {
|
||||
s := newTestServer()
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
s.actCancel = cancel
|
||||
|
||||
owner := unprivilegedIdentity()
|
||||
s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration())
|
||||
|
||||
require.NoError(t, s.cleanupConnection())
|
||||
|
||||
got, found := s.jwtCache.get(owner)
|
||||
require.True(t, found, "going down must not drop the cached SSH JWT")
|
||||
assert.Equal(t, "token", got)
|
||||
}
|
||||
|
||||
// fakeOAuthFlow stands in for the IdP round trip so a test can drive
|
||||
// WaitJWTToken without a real device-code flow.
|
||||
type fakeOAuthFlow struct {
|
||||
token string
|
||||
}
|
||||
|
||||
func (f *fakeOAuthFlow) RequestAuthInfo(context.Context) (auth.AuthFlowInfo, error) {
|
||||
return auth.AuthFlowInfo{DeviceCode: "device-code"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeOAuthFlow) WaitToken(context.Context, auth.AuthFlowInfo) (auth.TokenInfo, error) {
|
||||
return auth.TokenInfo{AccessToken: f.token}, nil
|
||||
}
|
||||
|
||||
func (f *fakeOAuthFlow) GetClientID(context.Context) string { return "client-id" }
|
||||
|
||||
// The flow outlives a profile switch, because SwitchProfile does not reset
|
||||
// s.oauthAuthFlow. A switch between RequestJWTAuth and the IdP answering must
|
||||
// still keep the token out of the cache the new profile uses, and the
|
||||
// generation the flow carries is what decides it: reading the cache's own
|
||||
// generation at store time would already be the new one.
|
||||
func TestWaitJWTToken_DropsTokenFromASessionThatEndedBeforeTheWait(t *testing.T) {
|
||||
s := newTestServer()
|
||||
owner := unprivilegedIdentity()
|
||||
ttl := int(testTTL.Seconds())
|
||||
s.config = &profilemanager.Config{SSHJWTCacheTTL: &ttl}
|
||||
|
||||
// RequestJWTAuth ran under the previous session and recorded its generation.
|
||||
s.oauthAuthFlow.flow = &fakeOAuthFlow{token: "token-from-the-old-session"}
|
||||
s.oauthAuthFlow.info = auth.AuthFlowInfo{DeviceCode: "device-code"}
|
||||
s.oauthAuthFlow.cacheGeneration = s.jwtCache.currentGeneration()
|
||||
|
||||
// A profile switch or a logout lands before the caller reaches WaitJWTToken.
|
||||
s.jwtCache.clear()
|
||||
|
||||
_, err := s.WaitJWTToken(ctxWithIdentity(owner), &proto.WaitJWTTokenRequest{DeviceCode: "device-code"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, found := s.jwtCache.get(owner)
|
||||
assert.False(t, found, "a token whose flow started under the previous session must not be cached")
|
||||
}
|
||||
Reference in New Issue
Block a user