Repository: juanfont/headscale

Issue:
[Bug] Version 0.27.0 when using oidc doesn't respect expiry parameter on session lengths
### Is this a support request?

- [x] This is not a support request

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

Using version 0.26.1, when you set an expiry time in oidc, it disconnects clients after time has passed but since update to version 0.27.0, expiration is set correctly: 2025-11-07 14:49:24 but after som time running, its set to 0001-01-01 00:00:00

### Expected Behavior

To keep the expiration time untouched.

### Steps To Reproduce

Use headscale with oidc:

```yaml
oidc:
    expiry: 72h
    use_expiry_from_token: false
```
### Environment

```markdown
- OS: Debian 12
- Headscale version: 0.27.0
- Tailscale version: 1.80 to 1.90.6 (Tested)
```

### Runtime environment

- [ ] Headscale is behind a (reverse) proxy
- [ ] Headscale runs in a container

### Debug information

...

Discussion:
@nblock: Confirmed, the expiration is correctly set (`2025-11-08 20:15:30`)  on initial authentication but is reset to ` 0001-01-01 00:00:00` when tailscaled is restarted on the node.

Solution patch:
diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml
index fe934aab..f5ec43a1 100644
--- a/.github/workflows/test-integration.yaml
+++ b/.github/workflows/test-integration.yaml
@@ -40,6 +40,7 @@ jobs:
           - TestOIDCFollowUpUrl
           - TestOIDCMultipleOpenedLoginUrls
           - TestOIDCReloginSameNodeSameUser
+          - TestOIDCExpiryAfterRestart
           - TestAuthWebFlowAuthenticationPingAll
           - TestAuthWebFlowLogoutAndReloginSameUser
           - TestAuthWebFlowLogoutAndReloginNewUser
diff --git a/hscontrol/auth.go b/hscontrol/auth.go
index e4a0d089..447035da 100644
--- a/hscontrol/auth.go
+++ b/hscontrol/auth.go
@@ -71,6 +71,13 @@ func (h *Headscale) handleRegister(
 		// We do not look up nodes by [key.MachinePublic] as it might belong to multiple
 		// nodes, separated by users and this path is handling expiring/logout paths.
 		if node, ok := h.state.GetNodeByNodeKey(req.NodeKey); ok {
+			// When tailscaled restarts, it sends RegisterRequest with Auth=nil and Expiry=zero.
+			// Return the current node state without modification.
+			// See: https://github.com/juanfont/headscale/issues/2862
+			if req.Expiry.IsZero() && node.Expiry().Valid() && !node.IsExpired() {
+				return nodeToRegisterResponse(node), nil
+			}
+
 			resp, err := h.handleLogout(node, req, machineKey)
 			if err != nil {
 				return nil, fmt.Errorf("handling existing node: %w", err)
@@ -173,6 +180,7 @@ func (h *Headscale) handleLogout(
 	}
 
 	// If the request expiry is in the past, we consider it a logout.
+	// Zero expiry is handled in handleRegister() before calling this function.
 	if req.Expiry.Before(time.Now()) {
 		log.Debug().
 			Uint64("node.id", node.ID().Uint64()).
diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go
index 3fddcfd2..244bb3db 100644
--- a/hscontrol/db/ip.go
+++ b/hscontrol/db/ip.go
@@ -325,7 +325,11 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) {
 			}
 
 			if changed {
-				err := tx.Save(node).Error
+				// Use Updates() with Select() to only update IP fields, avoiding overwriting
+				// other fields like Expiry. We need Select() because Updates() alone skips
+				// zero values, but we DO want to update IPv4/IPv6 to nil when removing them.
+				// See issue #2862.
+				err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error
 				if err != nil {
 					return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err)
 				}
diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go
index 70d3afaf..060196a9 100644
--- a/hscontrol/db/node.go
+++ b/hscontrol/db/node.go
@@ -452,13 +452,6 @@ func NodeSetMachineKey(
 	}).Error
 }
 
-// NodeSave saves a node object to the database, prefer to use a specific save method rather
-// than this. It is intended to be used when we are changing or.
-// TODO(kradalby): Remove this func, just use Save.
-func NodeSave(tx *gorm.DB, node *types.Node) error {
-	return tx.Save(node).Error
-}
-
 func generateGivenName(suppliedName string, randomSuffix bool) (string, error) {
 	// Strip invalid DNS characters for givenName
 	suppliedName = strings.ToLower(suppliedName)
diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go
index a36c1f13..94575269 100644
--- a/hscontrol/db/preauth_keys.go
+++ b/hscontrol/db/preauth_keys.go
@@ -145,11 +145,12 @@ func (hsdb *HSDatabase) ExpirePreAuthKey(k *types.PreAuthKey) error {
 
 // UsePreAuthKey marks a PreAuthKey as used.
 func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
-	k.Used = true
-	if err := tx.Save(k).Error; err != nil {
+	err := tx.Model(k).Update("used", true).Error
+	if err != nil {
 		return fmt.Errorf("failed to update key used status in the database: %w", err)
 	}
 
+	k.Used = true
 	return nil
 }
 
diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go
index 08ed048c..039933c7 100644
--- a/hscontrol/db/users.go
+++ b/hscontrol/db/users.go
@@ -102,7 +102,8 @@ func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
 
 	oldUser.Name = newName
 
-	if err := tx.Save(&oldUser).Error; err != nil {
+	err = tx.Updates(&oldUser).Error
+	if err != nil {
 		return err
 	}
 
diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go
index 6e1d08e0..297004fc 100644
--- a/hscontrol/state/state.go
+++ b/hscontrol/state/state.go
@@ -300,7 +300,9 @@ func (s *State) UpdateUser(userID types.UserID, updateFn func(*types.User) error
 			return nil, err
 		}
 
-		if err := tx.Save(user).Error; err != nil {
+		// Use Updates() to only update modified fields, preserving unchanged values.
+		err = tx.Updates(user).Error
+		if err != nil {
 			return nil, fmt.Errorf("updating user: %w", err)
 		}
 
@@ -386,7 +388,11 @@ func (s *State) persistNodeToDB(node types.NodeView) (types.NodeView, change.Cha
 
 	nodePtr := node.AsStruct()
 
-	if err := s.db.DB.Save(nodePtr).Error; err != nil {
+	// Use Omit("expiry") to prevent overwriting expiry during MapRequest updates.
+	// Expiry should only be updated through explicit SetNodeExpiry calls or re-registration.
+	// See: https://github.com/juanfont/headscale/issues/2862
+	err := s.db.DB.Omit("expiry").Updates(nodePtr).Error
+	if err != nil {
 		return types.NodeView{}, change.EmptySet, fmt.Errorf("saving node: %w", err)
 	}
 
@@ -1187,9 +1193,10 @@ func (s *State) HandleNodeFromAuthPath(
 			return types.NodeView{}, change.EmptySet, fmt.Errorf("node not found in NodeStore: %d", existingNodeSameUser.ID())
 		}
 
-		// Use the node from UpdateNode to save to database
 		_, err = hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
-			if err := tx.Save(updatedNodeView.AsStruct()).Error; err != nil {
+			// Use Updates() to preserve fields not modified by UpdateNode.
+			err := tx.Updates(updatedNodeView.AsStruct()).Error
+			if err != nil {
 				return nil, fmt.Errorf("failed to save node: %w", err)
 			}
 			return nil, nil
@@ -1406,9 +1413,10 @@ func (s *State) HandleNodeFromPreAuthKey(
 			return types.NodeView{}, change.EmptySet, fmt.Errorf("node not found in NodeStore: %d", existingNodeSameUser.ID())
 		}
 
-		// Use the node from UpdateNode to save to database
 		_, err = hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
-			if err := tx.Save(updatedNodeView.AsStruct()).Error; err != nil {
+			// Use Updates() to preserve fields not modified by UpdateNode.
+			err := tx.Updates(updatedNodeView.AsStruct()).Error
+			if err != nil {
 				return nil, fmt.Errorf("failed to save node: %w", err)
 			}
 



Test patch:
diff --git a/hscontrol/db/user_update_test.go b/hscontrol/db/user_update_test.go
new file mode 100644
index 00000000..180481e7
--- /dev/null
+++ b/hscontrol/db/user_update_test.go
@@ -0,0 +1,134 @@
+package db
+
+import (
+	"database/sql"
+	"testing"
+
+	"github.com/juanfont/headscale/hscontrol/types"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+)
+
+// TestUserUpdatePreservesUnchangedFields verifies that updating a user
+// preserves fields that aren't modified. This test validates the fix
+// for using Updates() instead of Save() in UpdateUser-like operations.
+func TestUserUpdatePreservesUnchangedFields(t *testing.T) {
+	database := dbForTest(t)
+
+	// Create a user with all fields set
+	initialUser := types.User{
+		Name:        "testuser",
+		DisplayName: "Test User Display",
+		Email:       "test@example.com",
+		ProviderIdentifier: sql.NullString{
+			String: "provider-123",
+			Valid:  true,
+		},
+	}
+
+	createdUser, err := database.CreateUser(initialUser)
+	require.NoError(t, err)
+	require.NotNil(t, createdUser)
+
+	// Verify initial state
+	assert.Equal(t, "testuser", createdUser.Name)
+	assert.Equal(t, "Test User Display", createdUser.DisplayName)
+	assert.Equal(t, "test@example.com", createdUser.Email)
+	assert.True(t, createdUser.ProviderIdentifier.Valid)
+	assert.Equal(t, "provider-123", createdUser.ProviderIdentifier.String)
+
+	// Simulate what UpdateUser does: load user, modify one field, save
+	_, err = Write(database.DB, func(tx *gorm.DB) (*types.User, error) {
+		user, err := GetUserByID(tx, types.UserID(createdUser.ID))
+		if err != nil {
+			return nil, err
+		}
+
+		// Modify ONLY DisplayName
+		user.DisplayName = "Updated Display Name"
+
+		// This is the line being tested - currently uses Save() which writes ALL fields, potentially overwriting unchanged ones
+		err = tx.Save(user).Error
+		if err != nil {
+			return nil, err
+		}
+
+		return user, nil
+	})
+	require.NoError(t, err)
+
+	// Read user back from database
+	updatedUser, err := Read(database.DB, func(rx *gorm.DB) (*types.User, error) {
+		return GetUserByID(rx, types.UserID(createdUser.ID))
+	})
+	require.NoError(t, err)
+
+	// Verify that DisplayName was updated
+	assert.Equal(t, "Updated Display Name", updatedUser.DisplayName)
+
+	// CRITICAL: Verify that other fields were NOT overwritten
+	// With Save(), these assertions should pass because the user object
+	// was loaded from DB and has all fields populated.
+	// But if Updates() is used, these will also pass (and it's safer).
+	assert.Equal(t, "testuser", updatedUser.Name, "Name should be preserved")
+	assert.Equal(t, "test@example.com", updatedUser.Email, "Email should be preserved")
+	assert.True(t, updatedUser.ProviderIdentifier.Valid, "ProviderIdentifier should be preserved")
+	assert.Equal(t, "provider-123", updatedUser.ProviderIdentifier.String, "ProviderIdentifier value should be preserved")
+}
+
+// TestUserUpdateWithUpdatesMethod tests that using Updates() instead of Save()
+// works correctly and only updates modified fields.
+func TestUserUpdateWithUpdatesMethod(t *testing.T) {
+	database := dbForTest(t)
+
+	// Create a user
+	initialUser := types.User{
+		Name:        "testuser",
+		DisplayName: "Original Display",
+		Email:       "original@example.com",
+		ProviderIdentifier: sql.NullString{
+			String: "provider-abc",
+			Valid:  true,
+		},
+	}
+
+	createdUser, err := database.CreateUser(initialUser)
+	require.NoError(t, err)
+
+	// Update using Updates() method
+	_, err = Write(database.DB, func(tx *gorm.DB) (*types.User, error) {
+		user, err := GetUserByID(tx, types.UserID(createdUser.ID))
+		if err != nil {
+			return nil, err
+		}
+
+		// Modify multiple fields
+		user.DisplayName = "New Display"
+		user.Email = "new@example.com"
+
+		// Use Updates() instead of Save()
+		err = tx.Updates(user).Error
+		if err != nil {
+			return nil, err
+		}
+
+		return user, nil
+	})
+	require.NoError(t, err)
+
+	// Verify changes
+	updatedUser, err := Read(database.DB, func(rx *gorm.DB) (*types.User, error) {
+		return GetUserByID(rx, types.UserID(createdUser.ID))
+	})
+	require.NoError(t, err)
+
+	// Verify updated fields
+	assert.Equal(t, "New Display", updatedUser.DisplayName)
+	assert.Equal(t, "new@example.com", updatedUser.Email)
+
+	// Verify preserved fields
+	assert.Equal(t, "testuser", updatedUser.Name)
+	assert.True(t, updatedUser.ProviderIdentifier.Valid)
+	assert.Equal(t, "provider-abc", updatedUser.ProviderIdentifier.String)
+}
diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go
index eebb8165..9040e5fd 100644
--- a/integration/auth_oidc_test.go
+++ b/integration/auth_oidc_test.go
@@ -1294,3 +1294,131 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) {
 		}
 	}, 60*time.Second, 2*time.Second, "validating user1 node is online after same-user OIDC relogin")
 }
+
+// TestOIDCExpiryAfterRestart validates that node expiry is preserved
+// when a tailscaled client restarts and reconnects to headscale.
+//
+// This test reproduces the bug reported in https://github.com/juanfont/headscale/issues/2862
+// where OIDC expiry was reset to 0001-01-01 00:00:00 after tailscaled restart.
+//
+// Test flow:
+// 1. Node logs in with OIDC (gets 72h expiry)
+// 2. Verify expiry is set correctly in headscale
+// 3. Restart tailscaled container (simulates daemon restart)
+// 4. Wait for reconnection
+// 5. Verify expiry is still set correctly (not zero).
+func TestOIDCExpiryAfterRestart(t *testing.T) {
+	IntegrationSkip(t)
+
+	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",
+		"HEADSCALE_OIDC_EXPIRY":             "72h",
+	}
+
+	err = scenario.CreateHeadscaleEnvWithLoginURL(
+		nil,
+		hsic.WithTestName("oidcexpiry"),
+		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)
+
+	// Create and login tailscale client
+	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 login and expiry at %s", time.Now().Format(TimestampFormat))
+
+	// Verify initial expiry is set
+	var initialExpiry time.Time
+	assert.EventuallyWithT(t, func(ct *assert.CollectT) {
+		nodes, err := headscale.ListNodes()
+		assert.NoError(ct, err)
+		assert.Len(ct, nodes, 1)
+
+		node := nodes[0]
+		assert.NotNil(ct, node.GetExpiry(), "Expiry should be set after OIDC login")
+
+		if node.GetExpiry() != nil {
+			expiryTime := node.GetExpiry().AsTime()
+			assert.False(ct, expiryTime.IsZero(), "Expiry should not be zero time")
+
+			initialExpiry = expiryTime
+			t.Logf("Initial expiry set to: %v (expires in %v)", expiryTime, time.Until(expiryTime))
+		}
+	}, 30*time.Second, 1*time.Second, "validating initial expiry after OIDC login")
+
+	// Now restart the tailscaled container
+	t.Logf("Restarting tailscaled container at %s", time.Now().Format(TimestampFormat))
+
+	err = ts.Restart()
+	require.NoError(t, err, "Failed to restart tailscaled container")
+
+	t.Logf("Tailscaled restarted, waiting for reconnection at %s", time.Now().Format(TimestampFormat))
+
+	// Wait for the node to come back online
+	assert.EventuallyWithT(t, func(ct *assert.CollectT) {
+		status, err := ts.Status()
+		if !assert.NoError(ct, err) {
+			return
+		}
+
+		if !assert.NotNil(ct, status) {
+			return
+		}
+
+		assert.Equal(ct, "Running", status.BackendState)
+	}, 60*time.Second, 2*time.Second, "waiting for tailscale to reconnect after restart")
+
+	// THE CRITICAL TEST: Verify expiry is still set correctly after restart
+	t.Logf("Validating expiry preservation after restart at %s", time.Now().Format(TimestampFormat))
+
+	assert.EventuallyWithT(t, func(ct *assert.CollectT) {
+		nodes, err := headscale.ListNodes()
+		assert.NoError(ct, err)
+		assert.Len(ct, nodes, 1, "Should still have exactly 1 node after restart")
+
+		node := nodes[0]
+		assert.NotNil(ct, node.GetExpiry(), "Expiry should NOT be nil after restart")
+
+		if node.GetExpiry() != nil {
+			expiryTime := node.GetExpiry().AsTime()
+
+			// This is the bug check - expiry should NOT be zero time
+			assert.False(ct, expiryTime.IsZero(),
+				"BUG: Expiry was reset to zero time after tailscaled restart! This is issue #2862")
+
+			// Expiry should be exactly the same as before restart
+			assert.Equal(ct, initialExpiry, expiryTime,
+				"Expiry should be exactly the same after restart, got %v, expected %v",
+				expiryTime, initialExpiry)
+
+			t.Logf("SUCCESS: Expiry preserved after restart: %v (expires in %v)",
+				expiryTime, time.Until(expiryTime))
+		}
+	}, 30*time.Second, 1*time.Second, "validating expiry preservation after restart")
+}