Files
netbird/client/server/jwt_cache_test.go

177 lines
5.2 KiB
Go

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.KnownForTest(ipcauth.Identity{UID: uid, GID: uid})
}
func windowsCaller(sid string) ipcauth.Identity {
return ipcauth.KnownForTest(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)
}