Skip to content

Commit 773a46a

Browse files
committed
integration: add test to replicate #2862
Signed-off-by: Kristoffer Dalby <[email protected]>
1 parent 4728a2b commit 773a46a

File tree

2 files changed

+129
-0
lines changed

2 files changed

+129
-0
lines changed

.github/workflows/test-integration.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
- TestOIDCFollowUpUrl
4141
- TestOIDCMultipleOpenedLoginUrls
4242
- TestOIDCReloginSameNodeSameUser
43+
- TestOIDCExpiryAfterRestart
4344
- TestAuthWebFlowAuthenticationPingAll
4445
- TestAuthWebFlowLogoutAndReloginSameUser
4546
- TestAuthWebFlowLogoutAndReloginNewUser

integration/auth_oidc_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,3 +1294,131 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) {
12941294
}
12951295
}, 60*time.Second, 2*time.Second, "validating user1 node is online after same-user OIDC relogin")
12961296
}
1297+
1298+
// TestOIDCExpiryAfterRestart validates that node expiry is preserved
1299+
// when a tailscaled client restarts and reconnects to headscale.
1300+
//
1301+
// This test reproduces the bug reported in https://github.com/juanfont/headscale/issues/2862
1302+
// where OIDC expiry was reset to 0001-01-01 00:00:00 after tailscaled restart.
1303+
//
1304+
// Test flow:
1305+
// 1. Node logs in with OIDC (gets 72h expiry)
1306+
// 2. Verify expiry is set correctly in headscale
1307+
// 3. Restart tailscaled container (simulates daemon restart)
1308+
// 4. Wait for reconnection
1309+
// 5. Verify expiry is still set correctly (not zero).
1310+
func TestOIDCExpiryAfterRestart(t *testing.T) {
1311+
IntegrationSkip(t)
1312+
1313+
scenario, err := NewScenario(ScenarioSpec{
1314+
OIDCUsers: []mockoidc.MockUser{
1315+
oidcMockUser("user1", true),
1316+
},
1317+
})
1318+
1319+
require.NoError(t, err)
1320+
defer scenario.ShutdownAssertNoPanics(t)
1321+
1322+
oidcMap := map[string]string{
1323+
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
1324+
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
1325+
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
1326+
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
1327+
"HEADSCALE_OIDC_EXPIRY": "72h",
1328+
}
1329+
1330+
err = scenario.CreateHeadscaleEnvWithLoginURL(
1331+
nil,
1332+
hsic.WithTestName("oidcexpiry"),
1333+
hsic.WithConfigEnv(oidcMap),
1334+
hsic.WithTLS(),
1335+
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
1336+
hsic.WithEmbeddedDERPServerOnly(),
1337+
hsic.WithDERPAsIP(),
1338+
)
1339+
requireNoErrHeadscaleEnv(t, err)
1340+
1341+
headscale, err := scenario.Headscale()
1342+
require.NoError(t, err)
1343+
1344+
// Create and login tailscale client
1345+
ts, err := scenario.CreateTailscaleNode("unstable", tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]))
1346+
require.NoError(t, err)
1347+
1348+
u, err := ts.LoginWithURL(headscale.GetEndpoint())
1349+
require.NoError(t, err)
1350+
1351+
_, err = doLoginURL(ts.Hostname(), u)
1352+
require.NoError(t, err)
1353+
1354+
t.Logf("Validating initial login and expiry at %s", time.Now().Format(TimestampFormat))
1355+
1356+
// Verify initial expiry is set
1357+
var initialExpiry time.Time
1358+
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
1359+
nodes, err := headscale.ListNodes()
1360+
assert.NoError(ct, err)
1361+
assert.Len(ct, nodes, 1)
1362+
1363+
node := nodes[0]
1364+
assert.NotNil(ct, node.GetExpiry(), "Expiry should be set after OIDC login")
1365+
1366+
if node.GetExpiry() != nil {
1367+
expiryTime := node.GetExpiry().AsTime()
1368+
assert.False(ct, expiryTime.IsZero(), "Expiry should not be zero time")
1369+
1370+
initialExpiry = expiryTime
1371+
t.Logf("Initial expiry set to: %v (expires in %v)", expiryTime, time.Until(expiryTime))
1372+
}
1373+
}, 30*time.Second, 1*time.Second, "validating initial expiry after OIDC login")
1374+
1375+
// Now restart the tailscaled container
1376+
t.Logf("Restarting tailscaled container at %s", time.Now().Format(TimestampFormat))
1377+
1378+
err = ts.Restart()
1379+
require.NoError(t, err, "Failed to restart tailscaled container")
1380+
1381+
t.Logf("Tailscaled restarted, waiting for reconnection at %s", time.Now().Format(TimestampFormat))
1382+
1383+
// Wait for the node to come back online
1384+
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
1385+
status, err := ts.Status()
1386+
if !assert.NoError(ct, err) {
1387+
return
1388+
}
1389+
1390+
if !assert.NotNil(ct, status) {
1391+
return
1392+
}
1393+
1394+
assert.Equal(ct, "Running", status.BackendState)
1395+
}, 60*time.Second, 2*time.Second, "waiting for tailscale to reconnect after restart")
1396+
1397+
// THE CRITICAL TEST: Verify expiry is still set correctly after restart
1398+
t.Logf("Validating expiry preservation after restart at %s", time.Now().Format(TimestampFormat))
1399+
1400+
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
1401+
nodes, err := headscale.ListNodes()
1402+
assert.NoError(ct, err)
1403+
assert.Len(ct, nodes, 1, "Should still have exactly 1 node after restart")
1404+
1405+
node := nodes[0]
1406+
assert.NotNil(ct, node.GetExpiry(), "Expiry should NOT be nil after restart")
1407+
1408+
if node.GetExpiry() != nil {
1409+
expiryTime := node.GetExpiry().AsTime()
1410+
1411+
// This is the bug check - expiry should NOT be zero time
1412+
assert.False(ct, expiryTime.IsZero(),
1413+
"BUG: Expiry was reset to zero time after tailscaled restart! This is issue #2862")
1414+
1415+
// Expiry should be exactly the same as before restart
1416+
assert.Equal(ct, initialExpiry, expiryTime,
1417+
"Expiry should be exactly the same after restart, got %v, expected %v",
1418+
expiryTime, initialExpiry)
1419+
1420+
t.Logf("SUCCESS: Expiry preserved after restart: %v (expires in %v)",
1421+
expiryTime, time.Until(expiryTime))
1422+
}
1423+
}, 30*time.Second, 1*time.Second, "validating expiry preservation after restart")
1424+
}

0 commit comments

Comments
 (0)