mirror of
https://github.com/juanfont/headscale.git
synced 2025-10-23 11:19:19 +02:00
This PR addresses some consistency issues that was introduced or discovered with the nodestore. nodestore: Now returns the node that is being put or updated when it is finished. This closes a race condition where when we read it back, we do not necessarily get the node with the given change and it ensures we get all the other updates from that batch write. auth: Authentication paths have been unified and simplified. It removes a lot of bad branches and ensures we only do the minimal work. A comprehensive auth test set has been created so we do not have to run integration tests to validate auth and it has allowed us to generate test cases for all the branches we currently know of. integration: added a lot more tooling and checks to validate that nodes reach the expected state when they come up and down. Standardised between the different auth models. A lot of this is to support or detect issues in the changes to nodestore (races) and auth (inconsistencies after login and reaching correct state) This PR was assisted, particularly tests, by claude code.
1169 lines
45 KiB
Go
1169 lines
45 KiB
Go
package integration
|
|
|
|
import (
|
|
"maps"
|
|
"net/netip"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/integration/hsic"
|
|
"github.com/juanfont/headscale/integration/tsic"
|
|
"github.com/oauth2-proxy/mockoidc"
|
|
"github.com/samber/lo"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestOIDCAuthenticationPingAll(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
// Logins to MockOIDC is served by a queue with a strict order,
|
|
// if we use more than one node per user, the order of the logins
|
|
// will not be deterministic and the test will fail.
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: 1,
|
|
Users: []string{"user1", "user2"},
|
|
OIDCUsers: []mockoidc.MockUser{
|
|
oidcMockUser("user1", true),
|
|
oidcMockUser("user2", false),
|
|
},
|
|
}
|
|
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
oidcMap := map[string]string{
|
|
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
|
}
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
nil,
|
|
hsic.WithTestName("oidcauthping"),
|
|
hsic.WithConfigEnv(oidcMap),
|
|
hsic.WithTLS(),
|
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
|
)
|
|
requireNoErrHeadscaleEnv(t, err)
|
|
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
requireNoErrListClients(t, err)
|
|
|
|
allIps, err := scenario.ListTailscaleClientsIPs()
|
|
requireNoErrListClientIPs(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
// assertClientsState(t, allClients)
|
|
|
|
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
|
|
return x.String()
|
|
})
|
|
|
|
success := pingAllHelper(t, allClients, allAddrs)
|
|
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
|
|
|
|
headscale, err := scenario.Headscale()
|
|
require.NoError(t, err)
|
|
|
|
listUsers, err := headscale.ListUsers()
|
|
require.NoError(t, err)
|
|
|
|
want := []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@test.no",
|
|
},
|
|
{
|
|
Id: 2,
|
|
Name: "user1",
|
|
Email: "user1@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user1",
|
|
},
|
|
{
|
|
Id: 3,
|
|
Name: "user2",
|
|
Email: "user2@test.no",
|
|
},
|
|
{
|
|
Id: 4,
|
|
Name: "user2",
|
|
Email: "", // Unverified
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user2",
|
|
},
|
|
}
|
|
|
|
sort.Slice(listUsers, func(i, j int) bool {
|
|
return listUsers[i].GetId() < listUsers[j].GetId()
|
|
})
|
|
|
|
if diff := cmp.Diff(want, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
|
|
t.Fatalf("unexpected users: %s", diff)
|
|
}
|
|
}
|
|
|
|
// TestOIDCExpireNodesBasedOnTokenExpiry validates that nodes correctly transition to NeedsLogin
|
|
// state when their OIDC tokens expire. This test uses a short token TTL to validate the
|
|
// expiration behavior without waiting for production-length timeouts.
|
|
//
|
|
// The test verifies:
|
|
// - Nodes can successfully authenticate via OIDC and establish connectivity
|
|
// - When OIDC tokens expire, nodes transition to NeedsLogin state
|
|
// - The expiration is based on individual token issue times, not a global timer
|
|
//
|
|
// Known timing considerations:
|
|
// - Nodes may expire at different times due to sequential login processing
|
|
// - The test must account for login time spread between first and last node.
|
|
func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
shortAccessTTL := 5 * time.Minute
|
|
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: 1,
|
|
Users: []string{"user1", "user2"},
|
|
OIDCUsers: []mockoidc.MockUser{
|
|
oidcMockUser("user1", true),
|
|
oidcMockUser("user2", false),
|
|
},
|
|
OIDCAccessTTL: shortAccessTTL,
|
|
}
|
|
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
oidcMap := map[string]string{
|
|
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
|
"HEADSCALE_OIDC_CLIENT_SECRET": scenario.mockOIDC.ClientSecret(),
|
|
"HEADSCALE_OIDC_USE_EXPIRY_FROM_TOKEN": "1",
|
|
}
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
nil,
|
|
hsic.WithTestName("oidcexpirenodes"),
|
|
hsic.WithConfigEnv(oidcMap),
|
|
)
|
|
requireNoErrHeadscaleEnv(t, err)
|
|
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
requireNoErrListClients(t, err)
|
|
|
|
allIps, err := scenario.ListTailscaleClientsIPs()
|
|
requireNoErrListClientIPs(t, err)
|
|
|
|
// Record when sync completes to better estimate token expiry timing
|
|
syncCompleteTime := time.Now()
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
loginDuration := time.Since(syncCompleteTime)
|
|
t.Logf("Login and sync completed in %v", loginDuration)
|
|
|
|
// assertClientsState(t, allClients)
|
|
|
|
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
|
|
return x.String()
|
|
})
|
|
|
|
success := pingAllHelper(t, allClients, allAddrs)
|
|
t.Logf("%d successful pings out of %d (before expiry)", success, len(allClients)*len(allIps))
|
|
|
|
// Wait for OIDC token expiry and verify all nodes transition to NeedsLogin.
|
|
// We add extra time to account for:
|
|
// - Sequential login processing causing different token issue times
|
|
// - Network and processing delays
|
|
// - Safety margin for test reliability
|
|
loginTimeSpread := 1 * time.Minute // Account for sequential login delays
|
|
safetyBuffer := 30 * time.Second // Additional safety margin
|
|
totalWaitTime := shortAccessTTL + loginTimeSpread + safetyBuffer
|
|
|
|
t.Logf("Waiting %v for OIDC tokens to expire (TTL: %v, spread: %v, buffer: %v)",
|
|
totalWaitTime, shortAccessTTL, loginTimeSpread, safetyBuffer)
|
|
|
|
// EventuallyWithT retries the test function until it passes or times out.
|
|
// IMPORTANT: Use 'ct' (CollectT) for all assertions inside the function, not 't'.
|
|
// Using 't' would cause immediate test failure without retries, defeating the purpose
|
|
// of EventuallyWithT which is designed to handle timing-dependent conditions.
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
// Check each client's status individually to provide better diagnostics
|
|
expiredCount := 0
|
|
for _, client := range allClients {
|
|
status, err := client.Status()
|
|
if assert.NoError(ct, err, "failed to get status for client %s", client.Hostname()) {
|
|
if status.BackendState == "NeedsLogin" {
|
|
expiredCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log progress for debugging
|
|
if expiredCount < len(allClients) {
|
|
t.Logf("Token expiry progress: %d/%d clients in NeedsLogin state", expiredCount, len(allClients))
|
|
}
|
|
|
|
// All clients must be in NeedsLogin state
|
|
assert.Equal(ct, len(allClients), expiredCount,
|
|
"expected all %d clients to be in NeedsLogin state, but only %d are",
|
|
len(allClients), expiredCount)
|
|
|
|
// Only check detailed logout state if all clients are expired
|
|
if expiredCount == len(allClients) {
|
|
assertTailscaleNodesLogout(ct, allClients)
|
|
}
|
|
}, totalWaitTime, 5*time.Second)
|
|
}
|
|
|
|
func TestOIDC024UserCreation(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
config map[string]string
|
|
emailVerified bool
|
|
cliUsers []string
|
|
oidcUsers []string
|
|
want func(iss string) []*v1.User
|
|
}{
|
|
{
|
|
name: "no-migration-verified-email",
|
|
emailVerified: true,
|
|
cliUsers: []string{"user1", "user2"},
|
|
oidcUsers: []string{"user1", "user2"},
|
|
want: func(iss string) []*v1.User {
|
|
return []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@test.no",
|
|
},
|
|
{
|
|
Id: 2,
|
|
Name: "user1",
|
|
Email: "user1@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: iss + "/user1",
|
|
},
|
|
{
|
|
Id: 3,
|
|
Name: "user2",
|
|
Email: "user2@test.no",
|
|
},
|
|
{
|
|
Id: 4,
|
|
Name: "user2",
|
|
Email: "user2@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: iss + "/user2",
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "no-migration-not-verified-email",
|
|
emailVerified: false,
|
|
cliUsers: []string{"user1", "user2"},
|
|
oidcUsers: []string{"user1", "user2"},
|
|
want: func(iss string) []*v1.User {
|
|
return []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@test.no",
|
|
},
|
|
{
|
|
Id: 2,
|
|
Name: "user1",
|
|
Provider: "oidc",
|
|
ProviderId: iss + "/user1",
|
|
},
|
|
{
|
|
Id: 3,
|
|
Name: "user2",
|
|
Email: "user2@test.no",
|
|
},
|
|
{
|
|
Id: 4,
|
|
Name: "user2",
|
|
Provider: "oidc",
|
|
ProviderId: iss + "/user2",
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "migration-no-strip-domains-not-verified-email",
|
|
emailVerified: false,
|
|
cliUsers: []string{"user1.headscale.net", "user2.headscale.net"},
|
|
oidcUsers: []string{"user1", "user2"},
|
|
want: func(iss string) []*v1.User {
|
|
return []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1.headscale.net",
|
|
Email: "user1.headscale.net@test.no",
|
|
},
|
|
{
|
|
Id: 2,
|
|
Name: "user1",
|
|
Provider: "oidc",
|
|
ProviderId: iss + "/user1",
|
|
},
|
|
{
|
|
Id: 3,
|
|
Name: "user2.headscale.net",
|
|
Email: "user2.headscale.net@test.no",
|
|
},
|
|
{
|
|
Id: 4,
|
|
Name: "user2",
|
|
Provider: "oidc",
|
|
ProviderId: iss + "/user2",
|
|
},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: 1,
|
|
}
|
|
spec.Users = append(spec.Users, tt.cliUsers...)
|
|
|
|
for _, user := range tt.oidcUsers {
|
|
spec.OIDCUsers = append(spec.OIDCUsers, oidcMockUser(user, tt.emailVerified))
|
|
}
|
|
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
oidcMap := map[string]string{
|
|
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
|
}
|
|
maps.Copy(oidcMap, tt.config)
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
nil,
|
|
hsic.WithTestName("oidcmigration"),
|
|
hsic.WithConfigEnv(oidcMap),
|
|
hsic.WithTLS(),
|
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
|
)
|
|
requireNoErrHeadscaleEnv(t, err)
|
|
|
|
// Ensure that the nodes have logged in, this is what
|
|
// triggers user creation via OIDC.
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
headscale, err := scenario.Headscale()
|
|
require.NoError(t, err)
|
|
|
|
want := tt.want(scenario.mockOIDC.Issuer())
|
|
|
|
listUsers, err := headscale.ListUsers()
|
|
require.NoError(t, err)
|
|
|
|
sort.Slice(listUsers, func(i, j int) bool {
|
|
return listUsers[i].GetId() < listUsers[j].GetId()
|
|
})
|
|
|
|
if diff := cmp.Diff(want, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
|
|
t.Errorf("unexpected users: %s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOIDCAuthenticationWithPKCE(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
// Single user with one node for testing PKCE flow
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: 1,
|
|
Users: []string{"user1"},
|
|
OIDCUsers: []mockoidc.MockUser{
|
|
oidcMockUser("user1", true),
|
|
},
|
|
}
|
|
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
oidcMap := map[string]string{
|
|
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
|
"HEADSCALE_OIDC_PKCE_ENABLED": "1", // Enable PKCE
|
|
}
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
nil,
|
|
hsic.WithTestName("oidcauthpkce"),
|
|
hsic.WithConfigEnv(oidcMap),
|
|
hsic.WithTLS(),
|
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
|
)
|
|
requireNoErrHeadscaleEnv(t, err)
|
|
|
|
// Get all clients and verify they can connect
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
requireNoErrListClients(t, err)
|
|
|
|
allIps, err := scenario.ListTailscaleClientsIPs()
|
|
requireNoErrListClientIPs(t, err)
|
|
|
|
err = scenario.WaitForTailscaleSync()
|
|
requireNoErrSync(t, err)
|
|
|
|
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
|
|
return x.String()
|
|
})
|
|
|
|
success := pingAllHelper(t, allClients, allAddrs)
|
|
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
|
|
}
|
|
|
|
// TestOIDCReloginSameNodeNewUser tests the scenario where:
|
|
// 1. A Tailscale client logs in with user1 (creates node1 for user1)
|
|
// 2. The same client logs out and logs in with user2 (creates node2 for user2)
|
|
// 3. The same client logs out and logs in with user1 again (reuses node1, node2 remains)
|
|
// This validates that OIDC relogin properly handles node reuse and cleanup.
|
|
func TestOIDCReloginSameNodeNewUser(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
// Create no nodes and no users
|
|
scenario, err := NewScenario(ScenarioSpec{
|
|
// First login creates the first OIDC user
|
|
// Second login logs in the same node, which creates a new node
|
|
// Third login logs in the same node back into the original user
|
|
OIDCUsers: []mockoidc.MockUser{
|
|
oidcMockUser("user1", true),
|
|
oidcMockUser("user2", true),
|
|
oidcMockUser("user1", true),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
oidcMap := map[string]string{
|
|
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
|
}
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
nil,
|
|
hsic.WithTestName("oidcauthrelog"),
|
|
hsic.WithConfigEnv(oidcMap),
|
|
hsic.WithTLS(),
|
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
|
hsic.WithEmbeddedDERPServerOnly(),
|
|
hsic.WithDERPAsIP(),
|
|
)
|
|
requireNoErrHeadscaleEnv(t, err)
|
|
|
|
headscale, err := scenario.Headscale()
|
|
require.NoError(t, err)
|
|
|
|
ts, err := scenario.CreateTailscaleNode("unstable", tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]))
|
|
require.NoError(t, err)
|
|
|
|
u, err := ts.LoginWithURL(headscale.GetEndpoint())
|
|
require.NoError(t, err)
|
|
|
|
_, err = doLoginURL(ts.Hostname(), u)
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Validating initial user creation at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listUsers, err := headscale.ListUsers()
|
|
assert.NoError(ct, err, "Failed to list users during initial validation")
|
|
assert.Len(ct, listUsers, 1, "Expected exactly 1 user after first login, got %d", len(listUsers))
|
|
wantUsers := []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user1",
|
|
},
|
|
}
|
|
|
|
sort.Slice(listUsers, func(i, j int) bool {
|
|
return listUsers[i].GetId() < listUsers[j].GetId()
|
|
})
|
|
|
|
if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
|
|
ct.Errorf("User validation failed after first login - unexpected users: %s", diff)
|
|
}
|
|
}, 30*time.Second, 1*time.Second, "validating user1 creation after initial OIDC login")
|
|
|
|
t.Logf("Validating initial node creation at %s", time.Now().Format(TimestampFormat))
|
|
var listNodes []*v1.Node
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
var err error
|
|
listNodes, err = headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes during initial validation")
|
|
assert.Len(ct, listNodes, 1, "Expected exactly 1 node after first login, got %d", len(listNodes))
|
|
}, 30*time.Second, 1*time.Second, "validating initial node creation for user1 after OIDC login")
|
|
|
|
// Collect expected node IDs for validation after user1 initial login
|
|
expectedNodes := make([]types.NodeID, 0, 1)
|
|
status := ts.MustStatus()
|
|
nodeID, err := strconv.ParseUint(string(status.Self.ID), 10, 64)
|
|
require.NoError(t, err)
|
|
expectedNodes = append(expectedNodes, types.NodeID(nodeID))
|
|
|
|
// Validate initial connection state for user1
|
|
validateInitialConnection(t, headscale, expectedNodes)
|
|
|
|
// Log out user1 and log in user2, this should create a new node
|
|
// for user2, the node should have the same machine key and
|
|
// a new node key.
|
|
err = ts.Logout()
|
|
require.NoError(t, err)
|
|
|
|
// TODO(kradalby): Not sure why we need to logout twice, but it fails and
|
|
// logs in immediately after the first logout and I cannot reproduce it
|
|
// manually.
|
|
err = ts.Logout()
|
|
require.NoError(t, err)
|
|
|
|
// Wait for logout to complete and then do second logout
|
|
t.Logf("Waiting for user1 logout completion at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
// Check that the first logout completed
|
|
status, err := ts.Status()
|
|
assert.NoError(ct, err, "Failed to get client status during logout validation")
|
|
assert.Equal(ct, "NeedsLogin", status.BackendState, "Expected NeedsLogin state after logout, got %s", status.BackendState)
|
|
}, 30*time.Second, 1*time.Second, "waiting for user1 logout to complete before user2 login")
|
|
|
|
u, err = ts.LoginWithURL(headscale.GetEndpoint())
|
|
require.NoError(t, err)
|
|
|
|
_, err = doLoginURL(ts.Hostname(), u)
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Validating user2 creation at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listUsers, err := headscale.ListUsers()
|
|
assert.NoError(ct, err, "Failed to list users after user2 login")
|
|
assert.Len(ct, listUsers, 2, "Expected exactly 2 users after user2 login, got %d users", len(listUsers))
|
|
wantUsers := []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user1",
|
|
},
|
|
{
|
|
Id: 2,
|
|
Name: "user2",
|
|
Email: "user2@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user2",
|
|
},
|
|
}
|
|
|
|
sort.Slice(listUsers, func(i, j int) bool {
|
|
return listUsers[i].GetId() < listUsers[j].GetId()
|
|
})
|
|
|
|
if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
|
|
ct.Errorf("User validation failed after user2 login - expected both user1 and user2: %s", diff)
|
|
}
|
|
}, 30*time.Second, 1*time.Second, "validating both user1 and user2 exist after second OIDC login")
|
|
|
|
var listNodesAfterNewUserLogin []*v1.Node
|
|
// First, wait for the new node to be created
|
|
t.Logf("Waiting for user2 node creation at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listNodesAfterNewUserLogin, err = headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes after user2 login")
|
|
// We might temporarily have more than 2 nodes during cleanup, so check for at least 2
|
|
assert.GreaterOrEqual(ct, len(listNodesAfterNewUserLogin), 2, "Should have at least 2 nodes after user2 login, got %d (may include temporary nodes during cleanup)", len(listNodesAfterNewUserLogin))
|
|
}, 30*time.Second, 1*time.Second, "waiting for user2 node creation (allowing temporary extra nodes during cleanup)")
|
|
|
|
// Then wait for cleanup to stabilize at exactly 2 nodes
|
|
t.Logf("Waiting for node cleanup stabilization at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listNodesAfterNewUserLogin, err = headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes during cleanup validation")
|
|
assert.Len(ct, listNodesAfterNewUserLogin, 2, "Should have exactly 2 nodes after cleanup (1 for user1, 1 for user2), got %d nodes", len(listNodesAfterNewUserLogin))
|
|
|
|
// Validate that both nodes have the same machine key but different node keys
|
|
if len(listNodesAfterNewUserLogin) >= 2 {
|
|
// Machine key is the same as the "machine" has not changed,
|
|
// but Node key is not as it is a new node
|
|
assert.Equal(ct, listNodes[0].GetMachineKey(), listNodesAfterNewUserLogin[0].GetMachineKey(), "Machine key should be preserved from original node")
|
|
assert.Equal(ct, listNodesAfterNewUserLogin[0].GetMachineKey(), listNodesAfterNewUserLogin[1].GetMachineKey(), "Both nodes should share the same machine key")
|
|
assert.NotEqual(ct, listNodesAfterNewUserLogin[0].GetNodeKey(), listNodesAfterNewUserLogin[1].GetNodeKey(), "Node keys should be different between user1 and user2 nodes")
|
|
}
|
|
}, 90*time.Second, 2*time.Second, "waiting for node count stabilization at exactly 2 nodes after user2 login")
|
|
|
|
// Security validation: Only user2's node should be active after user switch
|
|
var activeUser2NodeID types.NodeID
|
|
for _, node := range listNodesAfterNewUserLogin {
|
|
if node.GetUser().GetId() == 2 { // user2
|
|
activeUser2NodeID = types.NodeID(node.GetId())
|
|
t.Logf("Active user2 node: %d (User: %s)", node.GetId(), node.GetUser().GetName())
|
|
break
|
|
}
|
|
}
|
|
|
|
// Validate only user2's node is online (security requirement)
|
|
t.Logf("Validating only user2 node is online at %s", time.Now().Format(TimestampFormat))
|
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
nodeStore, err := headscale.DebugNodeStore()
|
|
assert.NoError(c, err, "Failed to get nodestore debug info")
|
|
|
|
// Check user2 node is online
|
|
if node, exists := nodeStore[activeUser2NodeID]; exists {
|
|
assert.NotNil(c, node.IsOnline, "User2 node should have online status")
|
|
if node.IsOnline != nil {
|
|
assert.True(c, *node.IsOnline, "User2 node should be online after login")
|
|
}
|
|
} else {
|
|
assert.Fail(c, "User2 node not found in nodestore")
|
|
}
|
|
}, 60*time.Second, 2*time.Second, "validating only user2 node is online after user switch")
|
|
|
|
// Before logging out user2, validate we have exactly 2 nodes and both are stable
|
|
t.Logf("Pre-logout validation: checking node stability at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
currentNodes, err := headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes before user2 logout")
|
|
assert.Len(ct, currentNodes, 2, "Should have exactly 2 stable nodes before user2 logout, got %d", len(currentNodes))
|
|
|
|
// Validate node stability - ensure no phantom nodes
|
|
for i, node := range currentNodes {
|
|
assert.NotNil(ct, node.GetUser(), "Node %d should have a valid user before logout", i)
|
|
assert.NotEmpty(ct, node.GetMachineKey(), "Node %d should have a valid machine key before logout", i)
|
|
t.Logf("Pre-logout node %d: User=%s, MachineKey=%s", i, node.GetUser().GetName(), node.GetMachineKey()[:16]+"...")
|
|
}
|
|
}, 60*time.Second, 2*time.Second, "validating stable node count and integrity before user2 logout")
|
|
|
|
// Log out user2, and log into user1, no new node should be created,
|
|
// the node should now "become" node1 again
|
|
err = ts.Logout()
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Logged out take one")
|
|
t.Log("timestamp: " + time.Now().Format(TimestampFormat) + "\n")
|
|
|
|
// TODO(kradalby): Not sure why we need to logout twice, but it fails and
|
|
// logs in immediately after the first logout and I cannot reproduce it
|
|
// manually.
|
|
err = ts.Logout()
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Logged out take two")
|
|
t.Log("timestamp: " + time.Now().Format(TimestampFormat) + "\n")
|
|
|
|
// Wait for logout to complete and then do second logout
|
|
t.Logf("Waiting for user2 logout completion at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
// Check that the first logout completed
|
|
status, err := ts.Status()
|
|
assert.NoError(ct, err, "Failed to get client status during user2 logout validation")
|
|
assert.Equal(ct, "NeedsLogin", status.BackendState, "Expected NeedsLogin state after user2 logout, got %s", status.BackendState)
|
|
}, 30*time.Second, 1*time.Second, "waiting for user2 logout to complete before user1 relogin")
|
|
|
|
// Before logging back in, ensure we still have exactly 2 nodes
|
|
// Note: We skip validateLogoutComplete here since it expects all nodes to be offline,
|
|
// but in OIDC scenario we maintain both nodes in DB with only active user online
|
|
|
|
// Additional validation that nodes are properly maintained during logout
|
|
t.Logf("Post-logout validation: checking node persistence at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
currentNodes, err := headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes after user2 logout")
|
|
assert.Len(ct, currentNodes, 2, "Should still have exactly 2 nodes after user2 logout (nodes should persist), got %d", len(currentNodes))
|
|
|
|
// Ensure both nodes are still valid (not cleaned up incorrectly)
|
|
for i, node := range currentNodes {
|
|
assert.NotNil(ct, node.GetUser(), "Node %d should still have a valid user after user2 logout", i)
|
|
assert.NotEmpty(ct, node.GetMachineKey(), "Node %d should still have a valid machine key after user2 logout", i)
|
|
t.Logf("Post-logout node %d: User=%s, MachineKey=%s", i, node.GetUser().GetName(), node.GetMachineKey()[:16]+"...")
|
|
}
|
|
}, 60*time.Second, 2*time.Second, "validating node persistence and integrity after user2 logout")
|
|
|
|
// We do not actually "change" the user here, it is done by logging in again
|
|
// as the OIDC mock server is kind of like a stack, and the next user is
|
|
// prepared and ready to go.
|
|
u, err = ts.LoginWithURL(headscale.GetEndpoint())
|
|
require.NoError(t, err)
|
|
|
|
_, err = doLoginURL(ts.Hostname(), u)
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Waiting for user1 relogin completion at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
status, err := ts.Status()
|
|
assert.NoError(ct, err, "Failed to get client status during user1 relogin validation")
|
|
assert.Equal(ct, "Running", status.BackendState, "Expected Running state after user1 relogin, got %s", status.BackendState)
|
|
}, 30*time.Second, 1*time.Second, "waiting for user1 relogin to complete (final login)")
|
|
|
|
t.Logf("Logged back in")
|
|
t.Log("timestamp: " + time.Now().Format(TimestampFormat) + "\n")
|
|
|
|
t.Logf("Final validation: checking user persistence at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listUsers, err := headscale.ListUsers()
|
|
assert.NoError(ct, err, "Failed to list users during final validation")
|
|
assert.Len(ct, listUsers, 2, "Should still have exactly 2 users after user1 relogin, got %d", len(listUsers))
|
|
wantUsers := []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user1",
|
|
},
|
|
{
|
|
Id: 2,
|
|
Name: "user2",
|
|
Email: "user2@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user2",
|
|
},
|
|
}
|
|
|
|
sort.Slice(listUsers, func(i, j int) bool {
|
|
return listUsers[i].GetId() < listUsers[j].GetId()
|
|
})
|
|
|
|
if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
|
|
ct.Errorf("Final user validation failed - both users should persist after relogin cycle: %s", diff)
|
|
}
|
|
}, 30*time.Second, 1*time.Second, "validating user persistence after complete relogin cycle (user1->user2->user1)")
|
|
|
|
var listNodesAfterLoggingBackIn []*v1.Node
|
|
// Wait for login to complete and nodes to stabilize
|
|
t.Logf("Final node validation: checking node stability after user1 relogin at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listNodesAfterLoggingBackIn, err = headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes during final validation")
|
|
|
|
// Allow for temporary instability during login process
|
|
if len(listNodesAfterLoggingBackIn) < 2 {
|
|
ct.Errorf("Not enough nodes yet during final validation, got %d, want at least 2", len(listNodesAfterLoggingBackIn))
|
|
return
|
|
}
|
|
|
|
// Final check should have exactly 2 nodes
|
|
assert.Len(ct, listNodesAfterLoggingBackIn, 2, "Should have exactly 2 nodes after complete relogin cycle, got %d", len(listNodesAfterLoggingBackIn))
|
|
|
|
// Validate that the machine we had when we logged in the first time, has the same
|
|
// machine key, but a different ID than the newly logged in version of the same
|
|
// machine.
|
|
assert.Equal(ct, listNodes[0].GetMachineKey(), listNodesAfterNewUserLogin[0].GetMachineKey(), "Original user1 machine key should match user1 node after user switch")
|
|
assert.Equal(ct, listNodes[0].GetNodeKey(), listNodesAfterNewUserLogin[0].GetNodeKey(), "Original user1 node key should match user1 node after user switch")
|
|
assert.Equal(ct, listNodes[0].GetId(), listNodesAfterNewUserLogin[0].GetId(), "Original user1 node ID should match user1 node after user switch")
|
|
assert.Equal(ct, listNodes[0].GetMachineKey(), listNodesAfterNewUserLogin[1].GetMachineKey(), "User1 and user2 nodes should share the same machine key")
|
|
assert.NotEqual(ct, listNodes[0].GetId(), listNodesAfterNewUserLogin[1].GetId(), "User1 and user2 nodes should have different node IDs")
|
|
assert.NotEqual(ct, listNodes[0].GetUser().GetId(), listNodesAfterNewUserLogin[1].GetUser().GetId(), "User1 and user2 nodes should belong to different users")
|
|
|
|
// Even tho we are logging in again with the same user, the previous key has been expired
|
|
// and a new one has been generated. The node entry in the database should be the same
|
|
// as the user + machinekey still matches.
|
|
assert.Equal(ct, listNodes[0].GetMachineKey(), listNodesAfterLoggingBackIn[0].GetMachineKey(), "Machine key should remain consistent after user1 relogin")
|
|
assert.NotEqual(ct, listNodes[0].GetNodeKey(), listNodesAfterLoggingBackIn[0].GetNodeKey(), "Node key should be regenerated after user1 relogin")
|
|
assert.Equal(ct, listNodes[0].GetId(), listNodesAfterLoggingBackIn[0].GetId(), "Node ID should be preserved for user1 after relogin")
|
|
|
|
// The "logged back in" machine should have the same machinekey but a different nodekey
|
|
// than the version logged in with a different user.
|
|
assert.Equal(ct, listNodesAfterLoggingBackIn[0].GetMachineKey(), listNodesAfterLoggingBackIn[1].GetMachineKey(), "Both final nodes should share the same machine key")
|
|
assert.NotEqual(ct, listNodesAfterLoggingBackIn[0].GetNodeKey(), listNodesAfterLoggingBackIn[1].GetNodeKey(), "Final nodes should have different node keys for different users")
|
|
|
|
t.Logf("Final validation complete - node counts and key relationships verified at %s", time.Now().Format(TimestampFormat))
|
|
}, 60*time.Second, 2*time.Second, "validating final node state after complete user1->user2->user1 relogin cycle with detailed key validation")
|
|
|
|
// Security validation: Only user1's node should be active after relogin
|
|
var activeUser1NodeID types.NodeID
|
|
for _, node := range listNodesAfterLoggingBackIn {
|
|
if node.GetUser().GetId() == 1 { // user1
|
|
activeUser1NodeID = types.NodeID(node.GetId())
|
|
t.Logf("Active user1 node after relogin: %d (User: %s)", node.GetId(), node.GetUser().GetName())
|
|
break
|
|
}
|
|
}
|
|
|
|
// Validate only user1's node is online (security requirement)
|
|
t.Logf("Validating only user1 node is online after relogin at %s", time.Now().Format(TimestampFormat))
|
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
nodeStore, err := headscale.DebugNodeStore()
|
|
assert.NoError(c, err, "Failed to get nodestore debug info")
|
|
|
|
// Check user1 node is online
|
|
if node, exists := nodeStore[activeUser1NodeID]; exists {
|
|
assert.NotNil(c, node.IsOnline, "User1 node should have online status after relogin")
|
|
if node.IsOnline != nil {
|
|
assert.True(c, *node.IsOnline, "User1 node should be online after relogin")
|
|
}
|
|
} else {
|
|
assert.Fail(c, "User1 node not found in nodestore after relogin")
|
|
}
|
|
}, 60*time.Second, 2*time.Second, "validating only user1 node is online after final relogin")
|
|
}
|
|
|
|
// TestOIDCFollowUpUrl validates the follow-up login flow
|
|
// Prerequisites:
|
|
// - short TTL for the registration cache via HEADSCALE_TUNING_REGISTER_CACHE_EXPIRATION
|
|
// Scenario:
|
|
// - client starts a login process and gets initial AuthURL
|
|
// - time.sleep(HEADSCALE_TUNING_REGISTER_CACHE_EXPIRATION + 30 secs) waits for the cache to expire
|
|
// - client checks its status to verify that AuthUrl has changed (by followup URL)
|
|
// - client uses the new AuthURL to log in. It should complete successfully.
|
|
func TestOIDCFollowUpUrl(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
// Create no nodes and no users
|
|
scenario, err := NewScenario(
|
|
ScenarioSpec{
|
|
OIDCUsers: []mockoidc.MockUser{
|
|
oidcMockUser("user1", true),
|
|
},
|
|
},
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
oidcMap := map[string]string{
|
|
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
|
// smaller cache expiration time to quickly expire AuthURL
|
|
"HEADSCALE_TUNING_REGISTER_CACHE_CLEANUP": "10s",
|
|
"HEADSCALE_TUNING_REGISTER_CACHE_EXPIRATION": "1m30s",
|
|
}
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
nil,
|
|
hsic.WithTestName("oidcauthrelog"),
|
|
hsic.WithConfigEnv(oidcMap),
|
|
hsic.WithTLS(),
|
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
|
hsic.WithEmbeddedDERPServerOnly(),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
headscale, err := scenario.Headscale()
|
|
require.NoError(t, err)
|
|
|
|
listUsers, err := headscale.ListUsers()
|
|
require.NoError(t, err)
|
|
assert.Empty(t, listUsers)
|
|
|
|
ts, err := scenario.CreateTailscaleNode(
|
|
"unstable",
|
|
tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
u, err := ts.LoginWithURL(headscale.GetEndpoint())
|
|
require.NoError(t, err)
|
|
|
|
// wait for the registration cache to expire
|
|
// a little bit more than HEADSCALE_TUNING_REGISTER_CACHE_EXPIRATION
|
|
time.Sleep(2 * time.Minute)
|
|
|
|
st, err := ts.Status()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "NeedsLogin", st.BackendState)
|
|
|
|
// get new AuthURL from daemon
|
|
newUrl, err := url.Parse(st.AuthURL)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEqual(t, u.String(), st.AuthURL, "AuthURL should change")
|
|
|
|
_, err = doLoginURL(ts.Hostname(), newUrl)
|
|
require.NoError(t, err)
|
|
|
|
listUsers, err = headscale.ListUsers()
|
|
require.NoError(t, err)
|
|
assert.Len(t, listUsers, 1)
|
|
|
|
wantUsers := []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user1",
|
|
},
|
|
}
|
|
|
|
sort.Slice(
|
|
listUsers, func(i, j int) bool {
|
|
return listUsers[i].GetId() < listUsers[j].GetId()
|
|
},
|
|
)
|
|
|
|
if diff := cmp.Diff(
|
|
wantUsers,
|
|
listUsers,
|
|
cmpopts.IgnoreUnexported(v1.User{}),
|
|
cmpopts.IgnoreFields(v1.User{}, "CreatedAt"),
|
|
); diff != "" {
|
|
t.Fatalf("unexpected users: %s", diff)
|
|
}
|
|
|
|
listNodes, err := headscale.ListNodes()
|
|
require.NoError(t, err)
|
|
assert.Len(t, listNodes, 1)
|
|
}
|
|
|
|
// TestOIDCReloginSameNodeSameUser tests the scenario where a single Tailscale client
|
|
// authenticates using OIDC (OpenID Connect), logs out, and then logs back in as the same user.
|
|
//
|
|
// OIDC is an authentication layer built on top of OAuth 2.0 that allows users to authenticate
|
|
// using external identity providers (like Google, Microsoft, etc.) rather than managing
|
|
// credentials directly in headscale.
|
|
//
|
|
// This test validates the "same user relogin" behavior in headscale's OIDC authentication flow:
|
|
// - A single client authenticates via OIDC as user1
|
|
// - The client logs out, ending the session
|
|
// - The same client logs back in via OIDC as the same user (user1)
|
|
// - The test verifies that the user account persists correctly
|
|
// - The test verifies that the machine key is preserved (since it's the same physical device)
|
|
// - The test verifies that the node ID is preserved (since it's the same user on the same device)
|
|
// - The test verifies that the node key is regenerated (since it's a new session)
|
|
// - The test verifies that the client comes back online properly
|
|
//
|
|
// This scenario is important for normal user workflows where someone might need to restart
|
|
// their Tailscale client, reboot their computer, or temporarily disconnect and reconnect.
|
|
// It ensures that headscale properly handles session management while preserving device
|
|
// identity and user associations.
|
|
//
|
|
// The test uses a single node scenario (unlike multi-node tests) to focus specifically on
|
|
// the authentication and session management aspects rather than network topology changes.
|
|
// The "same node" in the name refers to the same physical device/client, while "same user"
|
|
// refers to authenticating with the same OIDC identity.
|
|
func TestOIDCReloginSameNodeSameUser(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
// Create scenario with same user for both login attempts
|
|
scenario, err := NewScenario(ScenarioSpec{
|
|
OIDCUsers: []mockoidc.MockUser{
|
|
oidcMockUser("user1", true), // Initial login
|
|
oidcMockUser("user1", true), // Relogin with same user
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
oidcMap := map[string]string{
|
|
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
|
|
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
|
|
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
|
|
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
|
|
}
|
|
|
|
err = scenario.CreateHeadscaleEnvWithLoginURL(
|
|
nil,
|
|
hsic.WithTestName("oidcsameuser"),
|
|
hsic.WithConfigEnv(oidcMap),
|
|
hsic.WithTLS(),
|
|
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
|
|
hsic.WithEmbeddedDERPServerOnly(),
|
|
hsic.WithDERPAsIP(),
|
|
)
|
|
requireNoErrHeadscaleEnv(t, err)
|
|
|
|
headscale, err := scenario.Headscale()
|
|
require.NoError(t, err)
|
|
|
|
ts, err := scenario.CreateTailscaleNode("unstable", tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]))
|
|
require.NoError(t, err)
|
|
|
|
// Initial login as user1
|
|
u, err := ts.LoginWithURL(headscale.GetEndpoint())
|
|
require.NoError(t, err)
|
|
|
|
_, err = doLoginURL(ts.Hostname(), u)
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Validating initial user1 creation at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listUsers, err := headscale.ListUsers()
|
|
assert.NoError(ct, err, "Failed to list users during initial validation")
|
|
assert.Len(ct, listUsers, 1, "Expected exactly 1 user after first login, got %d", len(listUsers))
|
|
wantUsers := []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user1",
|
|
},
|
|
}
|
|
|
|
sort.Slice(listUsers, func(i, j int) bool {
|
|
return listUsers[i].GetId() < listUsers[j].GetId()
|
|
})
|
|
|
|
if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
|
|
ct.Errorf("User validation failed after first login - unexpected users: %s", diff)
|
|
}
|
|
}, 30*time.Second, 1*time.Second, "validating user1 creation after initial OIDC login")
|
|
|
|
t.Logf("Validating initial node creation at %s", time.Now().Format(TimestampFormat))
|
|
var initialNodes []*v1.Node
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
var err error
|
|
initialNodes, err = headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes during initial validation")
|
|
assert.Len(ct, initialNodes, 1, "Expected exactly 1 node after first login, got %d", len(initialNodes))
|
|
}, 30*time.Second, 1*time.Second, "validating initial node creation for user1 after OIDC login")
|
|
|
|
// Collect expected node IDs for validation after user1 initial login
|
|
expectedNodes := make([]types.NodeID, 0, 1)
|
|
status := ts.MustStatus()
|
|
nodeID, err := strconv.ParseUint(string(status.Self.ID), 10, 64)
|
|
require.NoError(t, err)
|
|
expectedNodes = append(expectedNodes, types.NodeID(nodeID))
|
|
|
|
// Validate initial connection state for user1
|
|
validateInitialConnection(t, headscale, expectedNodes)
|
|
|
|
// Store initial node keys for comparison
|
|
initialMachineKey := initialNodes[0].GetMachineKey()
|
|
initialNodeKey := initialNodes[0].GetNodeKey()
|
|
initialNodeID := initialNodes[0].GetId()
|
|
|
|
// Logout user1
|
|
err = ts.Logout()
|
|
require.NoError(t, err)
|
|
|
|
// TODO(kradalby): Not sure why we need to logout twice, but it fails and
|
|
// logs in immediately after the first logout and I cannot reproduce it
|
|
// manually.
|
|
err = ts.Logout()
|
|
require.NoError(t, err)
|
|
|
|
// Wait for logout to complete
|
|
t.Logf("Waiting for user1 logout completion at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
// Check that the logout completed
|
|
status, err := ts.Status()
|
|
assert.NoError(ct, err, "Failed to get client status during logout validation")
|
|
assert.Equal(ct, "NeedsLogin", status.BackendState, "Expected NeedsLogin state after logout, got %s", status.BackendState)
|
|
}, 30*time.Second, 1*time.Second, "waiting for user1 logout to complete before same-user relogin")
|
|
|
|
// Validate node persistence during logout (node should remain in DB)
|
|
t.Logf("Validating node persistence during logout at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listNodes, err := headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes during logout validation")
|
|
assert.Len(ct, listNodes, 1, "Should still have exactly 1 node during logout (node should persist in DB), got %d", len(listNodes))
|
|
}, 30*time.Second, 1*time.Second, "validating node persistence in database during same-user logout")
|
|
|
|
// Login again as the same user (user1)
|
|
u, err = ts.LoginWithURL(headscale.GetEndpoint())
|
|
require.NoError(t, err)
|
|
|
|
_, err = doLoginURL(ts.Hostname(), u)
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Waiting for user1 relogin completion at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
status, err := ts.Status()
|
|
assert.NoError(ct, err, "Failed to get client status during relogin validation")
|
|
assert.Equal(ct, "Running", status.BackendState, "Expected Running state after user1 relogin, got %s", status.BackendState)
|
|
}, 30*time.Second, 1*time.Second, "waiting for user1 relogin to complete (same user)")
|
|
|
|
t.Logf("Final validation: checking user persistence after same-user relogin at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
listUsers, err := headscale.ListUsers()
|
|
assert.NoError(ct, err, "Failed to list users during final validation")
|
|
assert.Len(ct, listUsers, 1, "Should still have exactly 1 user after same-user relogin, got %d", len(listUsers))
|
|
wantUsers := []*v1.User{
|
|
{
|
|
Id: 1,
|
|
Name: "user1",
|
|
Email: "user1@headscale.net",
|
|
Provider: "oidc",
|
|
ProviderId: scenario.mockOIDC.Issuer() + "/user1",
|
|
},
|
|
}
|
|
|
|
sort.Slice(listUsers, func(i, j int) bool {
|
|
return listUsers[i].GetId() < listUsers[j].GetId()
|
|
})
|
|
|
|
if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
|
|
ct.Errorf("Final user validation failed - user1 should persist after same-user relogin: %s", diff)
|
|
}
|
|
}, 30*time.Second, 1*time.Second, "validating user1 persistence after same-user OIDC relogin cycle")
|
|
|
|
var finalNodes []*v1.Node
|
|
t.Logf("Final node validation: checking node stability after same-user relogin at %s", time.Now().Format(TimestampFormat))
|
|
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
|
finalNodes, err = headscale.ListNodes()
|
|
assert.NoError(ct, err, "Failed to list nodes during final validation")
|
|
assert.Len(ct, finalNodes, 1, "Should have exactly 1 node after same-user relogin, got %d", len(finalNodes))
|
|
|
|
// Validate node key behavior for same user relogin
|
|
finalNode := finalNodes[0]
|
|
|
|
// Machine key should be preserved (same physical machine)
|
|
assert.Equal(ct, initialMachineKey, finalNode.GetMachineKey(), "Machine key should be preserved for same user same node relogin")
|
|
|
|
// Node ID should be preserved (same user, same machine)
|
|
assert.Equal(ct, initialNodeID, finalNode.GetId(), "Node ID should be preserved for same user same node relogin")
|
|
|
|
// Node key should be regenerated (new session after logout)
|
|
assert.NotEqual(ct, initialNodeKey, finalNode.GetNodeKey(), "Node key should be regenerated after logout/relogin even for same user")
|
|
|
|
t.Logf("Final validation complete - same user relogin key relationships verified at %s", time.Now().Format(TimestampFormat))
|
|
}, 60*time.Second, 2*time.Second, "validating final node state after same-user OIDC relogin cycle with key preservation validation")
|
|
|
|
// Security validation: user1's node should be active after relogin
|
|
activeUser1NodeID := types.NodeID(finalNodes[0].GetId())
|
|
t.Logf("Validating user1 node is online after same-user relogin at %s", time.Now().Format(TimestampFormat))
|
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
nodeStore, err := headscale.DebugNodeStore()
|
|
assert.NoError(c, err, "Failed to get nodestore debug info")
|
|
|
|
// Check user1 node is online
|
|
if node, exists := nodeStore[activeUser1NodeID]; exists {
|
|
assert.NotNil(c, node.IsOnline, "User1 node should have online status after same-user relogin")
|
|
if node.IsOnline != nil {
|
|
assert.True(c, *node.IsOnline, "User1 node should be online after same-user relogin")
|
|
}
|
|
} else {
|
|
assert.Fail(c, "User1 node not found in nodestore after same-user relogin")
|
|
}
|
|
}, 60*time.Second, 2*time.Second, "validating user1 node is online after same-user OIDC relogin")
|
|
}
|