Repository: juanfont/headscale

Issue:
[Bug] Can't split access for different users/groups to different Tailscale exit nodes in 0.26.1
### 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

Hello!

I can't split access for different users/groups to different Tailscale exit nodes in 0.26.1.

In documentation said that
```
Configure autogroup:internet in an ACL rule to restrict who can use any of the available exit nodes.
```

I tried ACL config
```
tagOwners:
  tag:exit-nodes:
  - group:exit-nodes
  tag:special-exit-nodes:
  - group:special-exit-nodes

autoApprovers:
  exitNode:
  - tag:exit-nodes
  - tag:special-exit-nodes

acls:
  - action: accept
    src:
    - group:general
    dst:
    - tag:exit-nodes:*

  - action: accept
    src:
    - group:vip
    dst:
    - tag:special-exit-nodes:*
```
but it doesn't work in Headscale 0.26.1. This scheme was worked in previous versions.

ACL with this rights
```
- action: accept
  src:
  - group:general
  dst:
  - autogroup:internet:*
```
grants access to all exit nodes.

### Expected Behavior

I can split access for different users/groups to different Tailscale exit nodes.

### Steps To Reproduce

1. Setup Headscale with ACL
```
- action: accept
  src:
  - group:general
  dst:
  - autogroup:internet:*
```
2. Add 2 exit nodes
3. Check access to internet via both nodes

### Environment

```markdown
OS: Ubuntu 24.04
Headscale version: 0.26.1
Tailscale version: 1.84.0
```

### Runtime environment

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

### Debug information

No

Hints from issue comments:
@nblock: From the Tailscale [ACL docs about subnet routers and exit nodes](https://tailscale.com/kb/1337/policy-syntax#subnet-routers-and-exit-nodes):

> Only devices with access to autogroup:internet can use exit nodes. All other devices (without access to autogroup:internet) cannot use exit nodes.
> ...
> You cannot restrict the use of specific exit nodes using ACLs.

See also the discussion in https://github.com/juanfont/headscale/issues/2469 and follow https://github.com/juanfont/headscale/issues/2409 for progress on the required feature to implement this: `via`.@spymobilfon: It's bad news for us. It has been working before implementing 'autogroup:internet'. The changes should have been marked as breaking changes.

Do you have timeline of supporting the feature?

Solution patch:
diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml
index 735c50bf..fe934aab 100644
--- a/.github/workflows/test-integration.yaml
+++ b/.github/workflows/test-integration.yaml
@@ -38,6 +38,7 @@ jobs:
           - TestOIDCAuthenticationWithPKCE
           - TestOIDCReloginSameNodeNewUser
           - TestOIDCFollowUpUrl
+          - TestOIDCMultipleOpenedLoginUrls
           - TestOIDCReloginSameNodeSameUser
           - TestAuthWebFlowAuthenticationPingAll
           - TestAuthWebFlowLogoutAndReloginSameUser
diff --git a/docs/ref/routes.md b/docs/ref/routes.md
index 9f32d9bc..a1c438b7 100644
--- a/docs/ref/routes.md
+++ b/docs/ref/routes.md
@@ -216,6 +216,39 @@ nodes.
 }
 ```
 
+### Restrict access to exit nodes per user or group
+
+A user can use _any_ of the available exit nodes with `autogroup:internet`. Alternatively, the ACL snippet below assigns
+each user a specific exit node while hiding all other exit nodes. The user `alice` can only use exit node `exit1` while
+user `bob` can only use exit node `exit2`.
+
+```json title="Assign each user a dedicated exit node"
+{
+  "hosts": {
+    "exit1": "100.64.0.1/32",
+    "exit2": "100.64.0.2/32"
+  },
+  "acls": [
+    {
+      "action": "accept",
+      "src": ["alice@"],
+      "dst": ["exit1:*"]
+    },
+    {
+      "action": "accept",
+      "src": ["bob@"],
+      "dst": ["exit2:*"]
+    }
+  ]
+}
+```
+
+!!! warning
+
+    - The above implementation is Headscale specific and will likely be removed once [support for
+      `via`](https://github.com/juanfont/headscale/issues/2409) is available.
+    - Beware that a user can also connect to any port of the exit node itself.
+
 ### Automatically approve an exit node with auto approvers
 
 The initial setup of an exit node usually requires manual approval on the control server before it can be used by a node
diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go
index 04c6cc0a..4eefee91 100644
--- a/hscontrol/db/db.go
+++ b/hscontrol/db/db.go
@@ -952,6 +952,41 @@ AND auth_key_id NOT IN (
 					return nil
 				},
 			},
+			{
+				// Drop all indices that are no longer in use and has existed.
+				// They potentially still present from broken migrations in the past.
+				// They should all be cleaned up by the db engine, but we are a bit
+				// conservative to ensure all our previous mess is cleaned up.
+				ID: "202511101554-drop-old-idx",
+				Migrate: func(tx *gorm.DB) error {
+					for _, oldIdx := range []struct{ name, table string }{
+						{"idx_namespaces_deleted_at", "namespaces"},
+						{"idx_routes_deleted_at", "routes"},
+						{"idx_shared_machines_deleted_at", "shared_machines"},
+					} {
+						err := tx.Migrator().DropIndex(oldIdx.table, oldIdx.name)
+						if err != nil {
+							log.Trace().
+								Str("index", oldIdx.name).
+								Str("table", oldIdx.table).
+								Err(err).
+								Msg("Error dropping old index, continuing...")
+						}
+					}
+
+					return nil
+				},
+				Rollback: func(tx *gorm.DB) error {
+					return nil
+				},
+			},
+
+			// Migrations **above** this points will be REMOVED in version **0.29.0**
+			// This is to clean up a lot of old migrations that is seldom used
+			// and carries a lot of technical debt.
+			// Any new migrations should be added after the comment below and follow
+			// the rules it sets out.
+
 			// From this point, the following rules must be followed:
 			// - NEVER use gorm.AutoMigrate, write the exact migration steps needed
 			// - AutoMigrate depends on the struct staying exactly the same, which it won't over time.
diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go
index 84d00712..7c7895c6 100644
--- a/hscontrol/oidc.go
+++ b/hscontrol/oidc.go
@@ -213,7 +213,8 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
 		return
 	}
 
-	cookieState, err := req.Cookie("state")
+	stateCookieName := getCookieName("state", state)
+	cookieState, err := req.Cookie(stateCookieName)
 	if err != nil {
 		httpError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
 		return
@@ -235,8 +236,13 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
 		httpError(writer, err)
 		return
 	}
+	if idToken.Nonce == "" {
+		httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err))
+		return
+	}
 
-	nonce, err := req.Cookie("nonce")
+	nonceCookieName := getCookieName("nonce", idToken.Nonce)
+	nonce, err := req.Cookie(nonceCookieName)
 	if err != nil {
 		httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
 		return
@@ -584,6 +590,11 @@ func renderOIDCCallbackTemplate(
 	return &content, nil
 }
 
+// getCookieName generates a unique cookie name based on a cookie value.
+func getCookieName(baseName, value string) string {
+	return fmt.Sprintf("%s_%s", baseName, value[:6])
+}
+
 func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) {
 	val, err := util.GenerateRandomStringURLSafe(64)
 	if err != nil {
@@ -592,7 +603,7 @@ func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string,
 
 	c := &http.Cookie{
 		Path:     "/oidc/callback",
-		Name:     name,
+		Name:     getCookieName(name, val),
 		Value:    val,
 		MaxAge:   int(time.Hour.Seconds()),
 		Secure:   r.TLS != nil,
diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go
index bb7d089a..dd8e70c5 100644
--- a/hscontrol/policy/v2/filter.go
+++ b/hscontrol/policy/v2/filter.go
@@ -316,7 +316,6 @@ func (pol *Policy) compileSSHPolicy(
 		srcIPs, err := rule.Sources.Resolve(pol, users, nodes)
 		if err != nil {
 			log.Trace().Caller().Err(err).Msgf("SSH policy compilation failed resolving source ips for rule %+v", rule)
-			continue // Skip this rule if we can't resolve sources
 		}
 
 		if srcIPs == nil || len(srcIPs.Prefixes()) == 0 {
diff --git a/integration/scenario.go b/integration/scenario.go
index aa844a7e..c3b5549c 100644
--- a/integration/scenario.go
+++ b/integration/scenario.go
@@ -860,47 +860,183 @@ func (s *Scenario) RunTailscaleUpWithURL(userStr, loginServer string) error {
 	return fmt.Errorf("failed to up tailscale node: %w", errNoUserAvailable)
 }
 
-// doLoginURL visits the given login URL and returns the body as a
-// string.
-func doLoginURL(hostname string, loginURL *url.URL) (string, error) {
-	log.Printf("%s login url: %s\n", hostname, loginURL.String())
+type debugJar struct {
+	inner *cookiejar.Jar
+	mu    sync.RWMutex
+	store map[string]map[string]map[string]*http.Cookie // domain -> path -> name -> cookie
+}
+
+func newDebugJar() (*debugJar, error) {
+	jar, err := cookiejar.New(nil)
+	if err != nil {
+		return nil, err
+	}
+	return &debugJar{
+		inner: jar,
+		store: make(map[string]map[string]map[string]*http.Cookie),
+	}, nil
+}
+
+func (j *debugJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
+	j.inner.SetCookies(u, cookies)
 
-	var err error
+	j.mu.Lock()
+	defer j.mu.Unlock()
+
+	for _, c := range cookies {
+		if c == nil || c.Name == "" {
+			continue
+		}
+		domain := c.Domain
+		if domain == "" {
+			domain = u.Hostname()
+		}
+		path := c.Path
+		if path == "" {
+			path = "/"
+		}
+		if _, ok := j.store[domain]; !ok {
+			j.store[domain] = make(map[string]map[string]*http.Cookie)
+		}
+		if _, ok := j.store[domain][path]; !ok {
+			j.store[domain][path] = make(map[string]*http.Cookie)
+		}
+		j.store[domain][path][c.Name] = copyCookie(c)
+	}
+}
+
+func (j *debugJar) Cookies(u *url.URL) []*http.Cookie {
+	return j.inner.Cookies(u)
+}
+
+func (j *debugJar) Dump(w io.Writer) {
+	j.mu.RLock()
+	defer j.mu.RUnlock()
+
+	for domain, paths := range j.store {
+		fmt.Fprintf(w, "Domain: %s\n", domain)
+		for path, byName := range paths {
+			fmt.Fprintf(w, "  Path: %s\n", path)
+			for _, c := range byName {
+				fmt.Fprintf(
+					w, "    %s=%s; Expires=%v; Secure=%v; HttpOnly=%v; SameSite=%v\n",
+					c.Name, c.Value, c.Expires, c.Secure, c.HttpOnly, c.SameSite,
+				)
+			}
+		}
+	}
+}
+
+func copyCookie(c *http.Cookie) *http.Cookie {
+	cc := *c
+	return &cc
+}
+
+func newLoginHTTPClient(hostname string) (*http.Client, error) {
 	hc := &http.Client{
 		Transport: LoggingRoundTripper{Hostname: hostname},
 	}
-	hc.Jar, err = cookiejar.New(nil)
+
+	jar, err := newDebugJar()
+	if err != nil {
+		return nil, fmt.Errorf("%s failed to create cookiejar: %w", hostname, err)
+	}
+
+	hc.Jar = jar
+
+	return hc, nil
+}
+
+// doLoginURL visits the given login URL and returns the body as a string.
+func doLoginURL(hostname string, loginURL *url.URL) (string, error) {
+	log.Printf("%s login url: %s\n", hostname, loginURL.String())
+
+	hc, err := newLoginHTTPClient(hostname)
+	if err != nil {
+		return "", err
+	}
+
+	body, _, err := doLoginURLWithClient(hostname, loginURL, hc, true)
 	if err != nil {
-		return "", fmt.Errorf("%s failed to create cookiejar	: %w", hostname, err)
+		return "", err
+	}
+
+	return body, nil
+}
+
+// doLoginURLWithClient performs the login request using the provided HTTP client.
+// When followRedirects is false, it will return the first redirect without following it.
+func doLoginURLWithClient(hostname string, loginURL *url.URL, hc *http.Client, followRedirects bool) (
+	string,
+	*url.URL,
+	error,
+) {
+	if hc == nil {
+		return "", nil, fmt.Errorf("%s http client is nil", hostname)
+	}
+
+	if loginURL == nil {
+		return "", nil, fmt.Errorf("%s login url is nil", hostname)
 	}
 
 	log.Printf("%s logging in with url: %s", hostname, loginURL.String())
 	ctx := context.Background()
-	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
+	if err != nil {
+		return "", nil, fmt.Errorf("%s failed to create http request: %w", hostname, err)
+	}
+
+	originalRedirect := hc.CheckRedirect
+	if !followRedirects {
+		hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		}
+	}
+	defer func() {
+		hc.CheckRedirect = originalRedirect
+	}()
+
 	resp, err := hc.Do(req)
 	if err != nil {
-		return "", fmt.Errorf("%s failed to send http request: %w", hostname, err)
+		return "", nil, fmt.Errorf("%s failed to send http request: %w", hostname, err)
+	}
+	defer resp.Body.Close()
+
+	bodyBytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", nil, fmt.Errorf("%s failed to read response body: %w", hostname, err)
 	}
+	body := string(bodyBytes)
 
-	log.Printf("cookies: %+v", hc.Jar.Cookies(loginURL))
+	var redirectURL *url.URL
+	if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest {
+		redirectURL, err = resp.Location()
+		if err != nil {
+			return body, nil, fmt.Errorf("%s failed to resolve redirect location: %w", hostname, err)
+		}
+	}
 
-	if resp.StatusCode != http.StatusOK {
-		body, _ := io.ReadAll(resp.Body)
+	if followRedirects && resp.StatusCode != http.StatusOK {
 		log.Printf("body: %s", body)
 
-		return "", fmt.Errorf("%s response code of login request was %w", hostname, err)
+		return body, redirectURL, fmt.Errorf("%s unexpected status code %d", hostname, resp.StatusCode)
 	}
 
-	defer resp.Body.Close()
+	if resp.StatusCode >= http.StatusBadRequest {
+		log.Printf("body: %s", body)
 
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		log.Printf("%s failed to read response body: %s", hostname, err)
+		return body, redirectURL, fmt.Errorf("%s unexpected status code %d", hostname, resp.StatusCode)
+	}
 
-		return "", fmt.Errorf("%s failed to read response body: %w", hostname, err)
+	if hc.Jar != nil {
+		if jar, ok := hc.Jar.(*debugJar); ok {
+			jar.Dump(os.Stdout)
+		} else {
+			log.Printf("cookies: %+v", hc.Jar.Cookies(loginURL))
+		}
 	}
 
-	return string(body), nil
+	return body, redirectURL, nil
 }
 
 var errParseAuthPage = errors.New("failed to parse auth page")



Test patch:
diff --git a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql
index 388fefbc..d911e960 100644
--- a/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql
+++ b/hscontrol/db/testdata/sqlite/headscale_0.26.1_dump_schema-to-0.27.0-old-table-cleanup.sql
@@ -31,10 +31,15 @@ CREATE UNIQUE INDEX idx_name_provider_identifier ON users (name,provider_identif
 CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users (name) WHERE provider_identifier IS NULL;
 
 -- Create all the old tables we have had and ensure they are clean up.
-CREATE TABLE `namespaces` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `namespaces` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`));
 CREATE TABLE `machines` (`id` text,PRIMARY KEY (`id`));
 CREATE TABLE `kvs` (`id` text,PRIMARY KEY (`id`));
-CREATE TABLE `shared_machines` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `shared_machines` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`));
 CREATE TABLE `pre_auth_key_acl_tags` (`id` text,PRIMARY KEY (`id`));
-CREATE TABLE `routes` (`id` text,PRIMARY KEY (`id`));
+CREATE TABLE `routes` (`id` text,`deleted_at` datetime,PRIMARY KEY (`id`));
+
+CREATE INDEX `idx_routes_deleted_at` ON `routes`(`deleted_at`);
+CREATE INDEX `idx_namespaces_deleted_at` ON `namespaces`(`deleted_at`);
+CREATE INDEX `idx_shared_machines_deleted_at` ON `shared_machines`(`deleted_at`);
+
 COMMIT;
diff --git a/hscontrol/policy/policy_test.go b/hscontrol/policy/policy_test.go
index c016fa58..10f6bf0a 100644
--- a/hscontrol/policy/policy_test.go
+++ b/hscontrol/policy/policy_test.go
@@ -1353,6 +1353,55 @@ func TestSSHPolicyRules(t *testing.T) {
 				},
 			}},
 		},
+		{
+			name:       "2863-allow-predefined-missing-users",
+			targetNode: taggedClient,
+			peers:      types.Nodes{&nodeUser2},
+			policy: `{
+  "groups": {
+   "group:example-infra": [
+      "user2@",
+      "not-created-yet@",
+    ],
+  },
+  "tagOwners": {
+    "tag:client": [
+      "user2@"
+    ],
+  },
+  "ssh": [
+    // Allow infra to ssh to tag:example-infra server as debian
+    {
+      "action": "accept",
+      "src": [
+        "group:example-infra"
+      ],
+      "dst": [
+        "tag:client",
+      ],
+      "users": [
+        "debian",
+      ],
+    },
+  ],
+}`,
+			wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
+				{
+					Principals: []*tailcfg.SSHPrincipal{
+						{NodeIP: "100.64.0.2"},
+					},
+					SSHUsers: map[string]string{
+						"debian": "debian",
+					},
+					Action: &tailcfg.SSHAction{
+						Accept:                    true,
+						AllowAgentForwarding:      true,
+						AllowLocalPortForwarding:  true,
+						AllowRemotePortForwarding: true,
+					},
+				},
+			}},
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go
index 0a0b5b95..eebb8165 100644
--- a/integration/auth_oidc_test.go
+++ b/integration/auth_oidc_test.go
@@ -953,6 +953,119 @@ func TestOIDCFollowUpUrl(t *testing.T) {
 	}, 10*time.Second, 200*time.Millisecond, "Waiting for expected node list after OIDC login")
 }
 
+// TestOIDCMultipleOpenedLoginUrls tests the scenario:
+// - client (mostly Windows) opens multiple browser tabs with different login URLs
+// - client performs auth on the first opened browser tab
+//
+// This test makes sure that cookies are still valid for the first browser tab.
+func TestOIDCMultipleOpenedLoginUrls(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",
+	}
+
+	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)
+
+	u1, err := ts.LoginWithURL(headscale.GetEndpoint())
+	require.NoError(t, err)
+
+	u2, err := ts.LoginWithURL(headscale.GetEndpoint())
+	require.NoError(t, err)
+
+	// make sure login URLs are different
+	require.NotEqual(t, u1.String(), u2.String())
+
+	loginClient, err := newLoginHTTPClient(ts.Hostname())
+	require.NoError(t, err)
+
+	// open the first login URL "in browser"
+	_, redirect1, err := doLoginURLWithClient(ts.Hostname(), u1, loginClient, false)
+	require.NoError(t, err)
+	// open the second login URL "in browser"
+	_, redirect2, err := doLoginURLWithClient(ts.Hostname(), u2, loginClient, false)
+	require.NoError(t, err)
+
+	// two valid redirects with different state/nonce params
+	require.NotEqual(t, redirect1.String(), redirect2.String())
+
+	// complete auth with the first opened "browser tab"
+	_, redirect1, err = doLoginURLWithClient(ts.Hostname(), redirect1, loginClient, true)
+	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)
+	}
+
+	assert.EventuallyWithT(
+		t, func(c *assert.CollectT) {
+			listNodes, err := headscale.ListNodes()
+			assert.NoError(c, err)
+			assert.Len(c, listNodes, 1)
+		}, 10*time.Second, 200*time.Millisecond, "Waiting for expected node list after OIDC login",
+	)
+}
+
 // 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.
 //