@@ -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