Repository: juanfont/headscale

Issue:
[Bug] Primary route no longer shown in CLI
### 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

The `routes list` command of Headscale 0.25 or earlier has a `Primary` column to indicate which route is actually used when multiple ones are available:

```
ID | Node     | Prefix                | Advertised | Enabled | Primary
1  | n1       | 10.10.10.0/24         | true       | true    | true   
...
```

The `Primary` column is no longer present in the output of the new `nodes list-routes` command:

```
ID | Hostname | Approved        | Available       | Serving        
1  | n1       | 10.10.10.0/24   | 10.10.10.0/24   | 10.10.10.0/24                    
```

The information is available via the debug endpoint /debug/routes.

### Expected Behavior

Have a column `Primary` in the output of  `nodes list-routes`.

### Steps To Reproduce

Compare output of `nodes list-routes` with the content of the `/debug/routes` debug endpoint.

### Environment

```markdown
- OS: Debian 12
- Headscale version: 586a20fbff4e97519d68a7fdd0d75e1c7decec30
- Tailscale version: -
```

### Runtime environment

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

### Anything else?

_No response_

Discussion:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bda04ed..c5d5f36c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -83,6 +83,10 @@ The new policy can be used by setting the environment variable
   - It is now possible to inspect running goroutines and take profiles
   - View of config, policy, filter, ssh policy per node, connected nodes and
     DERPmap
+- OIDC: Fetch UserInfo to get EmailVerified if necessary
+  [#2493](https://github.com/juanfont/headscale/pull/2493)
+  - If a OIDC provider doesn't include the `email_verified` claim in its ID
+    tokens, Headscale will attempt to get it from the UserInfo endpoint.
 
 ## 0.25.1 (2025-02-25)
 
diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go
index 2766efb9..9234cc49 100644
--- a/cmd/headscale/cli/nodes.go
+++ b/cmd/headscale/cli/nodes.go
@@ -729,7 +729,7 @@ func nodeRoutesToPtables(
 		"Hostname",
 		"Approved",
 		"Available",
-		"Serving",
+		"Serving (Primary)",
 	}
 	tableData := pterm.TableData{tableHeader}
 
diff --git a/flake.lock b/flake.lock
index d610a3f0..462c97fe 100644
--- a/flake.lock
+++ b/flake.lock
@@ -20,11 +20,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1740791350,
-        "narHash": "sha256-igS2Z4tVw5W/x3lCZeeadt0vcU9fxtetZ/RyrqsCRQ0=",
+        "lastModified": 1742578646,
+        "narHash": "sha256-GiQ40ndXRnmmbDZvuv762vS+gew1uDpFwOfgJ8tLiEs=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "199169a2135e6b864a888e89a2ace345703c025d",
+        "rev": "94c4dbe77c0740ebba36c173672ca15a7926c993",
         "type": "github"
       },
       "original": {
diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go
index 66f2b02f..c77b2411 100644
--- a/hscontrol/grpcv1.go
+++ b/hscontrol/grpcv1.go
@@ -27,6 +27,7 @@ import (
 	v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
 	"github.com/juanfont/headscale/hscontrol/db"
 	"github.com/juanfont/headscale/hscontrol/policy"
+	"github.com/juanfont/headscale/hscontrol/routes"
 	"github.com/juanfont/headscale/hscontrol/types"
 	"github.com/juanfont/headscale/hscontrol/util"
 )
@@ -349,7 +350,7 @@ func (api headscaleV1APIServer) SetApprovedRoutes(
 		}
 	}
 	tsaddr.SortPrefixes(routes)
-	slices.Compact(routes)
+	routes = slices.Compact(routes)
 
 	node, err := db.Write(api.h.db.DB, func(tx *gorm.DB) (*types.Node, error) {
 		err := db.SetApprovedRoutes(tx, types.NodeID(request.GetNodeId()), routes)
@@ -371,7 +372,10 @@ func (api headscaleV1APIServer) SetApprovedRoutes(
 		api.h.nodeNotifier.NotifyWithIgnore(ctx, types.UpdatePeerChanged(node.ID), node.ID)
 	}
 
-	return &v1.SetApprovedRoutesResponse{Node: node.Proto()}, nil
+	proto := node.Proto()
+	proto.SubnetRoutes = util.PrefixesToString(api.h.primaryRoutes.PrimaryRoutes(node.ID))
+
+	return &v1.SetApprovedRoutesResponse{Node: proto}, nil
 }
 
 func validateTag(tag string) error {
@@ -497,7 +501,7 @@ func (api headscaleV1APIServer) ListNodes(
 			return nil, err
 		}
 
-		response := nodesToProto(api.h.polMan, isLikelyConnected, nodes)
+		response := nodesToProto(api.h.polMan, isLikelyConnected, api.h.primaryRoutes, nodes)
 		return &v1.ListNodesResponse{Nodes: response}, nil
 	}
 
@@ -510,11 +514,11 @@ func (api headscaleV1APIServer) ListNodes(
 		return nodes[i].ID < nodes[j].ID
 	})
 
-	response := nodesToProto(api.h.polMan, isLikelyConnected, nodes)
+	response := nodesToProto(api.h.polMan, isLikelyConnected, api.h.primaryRoutes, nodes)
 	return &v1.ListNodesResponse{Nodes: response}, nil
 }
 
-func nodesToProto(polMan policy.PolicyManager, isLikelyConnected *xsync.MapOf[types.NodeID, bool], nodes types.Nodes) []*v1.Node {
+func nodesToProto(polMan policy.PolicyManager, isLikelyConnected *xsync.MapOf[types.NodeID, bool], pr *routes.PrimaryRoutes, nodes types.Nodes) []*v1.Node {
 	response := make([]*v1.Node, len(nodes))
 	for index, node := range nodes {
 		resp := node.Proto()
@@ -532,6 +536,7 @@ func nodesToProto(polMan policy.PolicyManager, isLikelyConnected *xsync.MapOf[ty
 			}
 		}
 		resp.ValidTags = lo.Uniq(append(tags, node.ForcedTags...))
+		resp.SubnetRoutes = util.PrefixesToString(append(pr.PrimaryRoutes(node.ID), node.ExitRoutes()...))
 		response[index] = resp
 	}
 
diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go
index a1807717..85566d0f 100644
--- a/hscontrol/oidc.go
+++ b/hscontrol/oidc.go
@@ -234,7 +234,14 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
 		return
 	}
 
-	idToken, err := a.extractIDToken(req.Context(), code, state)
+	oauth2Token, err := a.getOauth2Token(req.Context(), code, state)
+
+	if err != nil {
+		httpError(writer, err)
+		return
+	}
+
+	idToken, err := a.extractIDToken(req.Context(), oauth2Token)
 	if err != nil {
 		httpError(writer, err)
 		return
@@ -273,6 +280,16 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
 		return
 	}
 
+	// If EmailVerified is missing, we can try to get it from UserInfo
+	if !claims.EmailVerified {
+		var userinfo *oidc.UserInfo
+		userinfo, err = a.oidcProvider.UserInfo(req.Context(), oauth2.StaticTokenSource(oauth2Token))
+		if err != nil {
+			util.LogErr(err, "could not get userinfo; email cannot be verified")
+		}
+		claims.EmailVerified = types.FlexibleBoolean(userinfo.EmailVerified)
+	}
+
 	user, err := a.createOrUpdateUserFromClaim(&claims)
 	if err != nil {
 		httpError(writer, err)
@@ -333,13 +350,12 @@ func extractCodeAndStateParamFromRequest(
 	return code, state, nil
 }
 
-// extractIDToken takes the code parameter from the callback
-// and extracts the ID token from the oauth2 token.
-func (a *AuthProviderOIDC) extractIDToken(
+// getOauth2Token exchanges the code from the callback for an oauth2 token.
+func (a *AuthProviderOIDC) getOauth2Token(
 	ctx context.Context,
 	code string,
 	state string,
-) (*oidc.IDToken, error) {
+) (*oauth2.Token, error) {
 	var exchangeOpts []oauth2.AuthCodeOption
 
 	if a.cfg.PKCE.Enabled {
@@ -356,7 +372,14 @@ func (a *AuthProviderOIDC) extractIDToken(
 	if err != nil {
 		return nil, NewHTTPError(http.StatusForbidden, "invalid code", fmt.Errorf("could not exchange code for token: %w", err))
 	}
+	return oauth2Token, err
+}
 
+// extractIDToken extracts the ID token from the oauth2 token.
+func (a *AuthProviderOIDC) extractIDToken(
+	ctx context.Context,
+	oauth2Token *oauth2.Token,
+) (*oidc.IDToken, error) {
 	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
 	if !ok {
 		return nil, NewHTTPError(http.StatusBadRequest, "no id_token", errNoOIDCIDToken)
diff --git a/hscontrol/poll.go b/hscontrol/poll.go
index 6c11bb04..e4178f43 100644
--- a/hscontrol/poll.go
+++ b/hscontrol/poll.go
@@ -458,29 +458,31 @@ func (m *mapSession) handleEndpointUpdate() {
 		// TODO(kradalby): I am not sure if we need this?
 		nodesChangedHook(m.h.db, m.h.polMan, m.h.nodeNotifier)
 
-		// Approve routes if they are auto-approved by the policy.
-		// If any of them are approved, report them to the primary route tracker
-		// and send updates accordingly.
-		if policy.AutoApproveRoutes(m.h.polMan, m.node) {
-			if m.h.primaryRoutes.SetRoutes(m.node.ID, m.node.SubnetRoutes()...) {
-				ctx := types.NotifyCtx(m.ctx, "poll-primary-change", m.node.Hostname)
-				m.h.nodeNotifier.NotifyAll(ctx, types.UpdateFull())
-			} else {
-				ctx := types.NotifyCtx(m.ctx, "cli-approveroutes", m.node.Hostname)
-				m.h.nodeNotifier.NotifyWithIgnore(ctx, types.UpdatePeerChanged(m.node.ID), m.node.ID)
-
-				// TODO(kradalby): I am not sure if we need this?
-				// Send an update to the node itself with to ensure it
-				// has an updated packetfilter allowing the new route
-				// if it is defined in the ACL.
-				ctx = types.NotifyCtx(m.ctx, "poll-nodeupdate-self-hostinfochange", m.node.Hostname)
-				m.h.nodeNotifier.NotifyByNodeID(
-					ctx,
-					types.UpdateSelf(m.node.ID),
-					m.node.ID)
-			}
+		// Approve any route that has been defined in policy as
+		// auto approved. Any change here is not important as any
+		// actual state change will be detected when the route manager
+		// is updated.
+		policy.AutoApproveRoutes(m.h.polMan, m.node)
+
+		// Update the routes of the given node in the route manager to
+		// see if an update needs to be sent.
+		if m.h.primaryRoutes.SetRoutes(m.node.ID, m.node.SubnetRoutes()...) {
+			ctx := types.NotifyCtx(m.ctx, "poll-primary-change", m.node.Hostname)
+			m.h.nodeNotifier.NotifyAll(ctx, types.UpdateFull())
+		} else {
+			ctx := types.NotifyCtx(m.ctx, "cli-approveroutes", m.node.Hostname)
+			m.h.nodeNotifier.NotifyWithIgnore(ctx, types.UpdatePeerChanged(m.node.ID), m.node.ID)
+
+			// TODO(kradalby): I am not sure if we need this?
+			// Send an update to the node itself with to ensure it
+			// has an updated packetfilter allowing the new route
+			// if it is defined in the ACL.
+			ctx = types.NotifyCtx(m.ctx, "poll-nodeupdate-self-hostinfochange", m.node.Hostname)
+			m.h.nodeNotifier.NotifyByNodeID(
+				ctx,
+				types.UpdateSelf(m.node.ID),
+				m.node.ID)
 		}
-
 	}
 
 	// Check if there has been a change to Hostname and update them
@@ -506,8 +508,6 @@ func (m *mapSession) handleEndpointUpdate() {
 
 	m.w.WriteHeader(http.StatusOK)
 	mapResponseEndpointUpdates.WithLabelValues("ok").Inc()
-
-	return
 }
 
 func (m *mapSession) handleReadOnlyRequest() {
@@ -532,8 +532,6 @@ func (m *mapSession) handleReadOnlyRequest() {
 
 	m.w.WriteHeader(http.StatusOK)
 	mapResponseReadOnly.WithLabelValues("ok").Inc()
-
-	return
 }
 
 func logTracePeerChange(hostname string, hostinfoChange bool, change *tailcfg.PeerChange) {
diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go
index 767ccdff..c333a148 100644
--- a/hscontrol/types/node.go
+++ b/hscontrol/types/node.go
@@ -247,13 +247,7 @@ func (node *Node) IPsAsString() []string {
 }
 
 func (node *Node) InIPSet(set *netipx.IPSet) bool {
-	for _, nodeAddr := range node.IPs() {
-		if set.Contains(nodeAddr) {
-			return true
-		}
-	}
-
-	return false
+	return slices.ContainsFunc(node.IPs(), set.Contains)
 }
 
 // AppendToIPSet adds the individual ips in NodeAddresses to a
@@ -329,14 +323,17 @@ func (node *Node) Proto() *v1.Node {
 		DiscoKey: node.DiscoKey.String(),
 
 		// TODO(kradalby): replace list with v4, v6 field?
-		IpAddresses:     node.IPsAsString(),
-		Name:            node.Hostname,
-		GivenName:       node.GivenName,
-		User:            node.User.Proto(),
-		ForcedTags:      node.ForcedTags,
+		IpAddresses: node.IPsAsString(),
+		Name:        node.Hostname,
+		GivenName:   node.GivenName,
+		User:        node.User.Proto(),
+		ForcedTags:  node.ForcedTags,
+
+		// Only ApprovedRoutes and AvailableRoutes is set here. SubnetRoutes has
+		// to be populated manually with PrimaryRoute, to ensure it includes the
+		// routes that are actively served from the node.
 		ApprovedRoutes:  util.PrefixesToString(node.ApprovedRoutes),
 		AvailableRoutes: util.PrefixesToString(node.AnnouncedRoutes()),
-		SubnetRoutes:    util.PrefixesToString(node.SubnetRoutes()),
 
 		RegisterMethod: node.RegisterMethodToV1Enum(),
 



Test patch:
diff --git a/integration/route_test.go b/integration/route_test.go
index 04f9073e..51f20e9e 100644
--- a/integration/route_test.go
+++ b/integration/route_test.go
@@ -375,7 +375,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
 	assert.Len(t, nodes, 6)
 
 	assertNodeRouteCount(t, nodes[0], 1, 1, 1)
-	assertNodeRouteCount(t, nodes[1], 1, 1, 1)
+	assertNodeRouteCount(t, nodes[1], 1, 1, 0)
 	assertNodeRouteCount(t, nodes[2], 1, 0, 0)
 
 	// Verify that the client has routes from the primary machine
@@ -431,8 +431,8 @@ func TestHASubnetRouterFailover(t *testing.T) {
 	assert.Len(t, nodes, 6)
 
 	assertNodeRouteCount(t, nodes[0], 1, 1, 1)
-	assertNodeRouteCount(t, nodes[1], 1, 1, 1)
-	assertNodeRouteCount(t, nodes[2], 1, 1, 1)
+	assertNodeRouteCount(t, nodes[1], 1, 1, 0)
+	assertNodeRouteCount(t, nodes[2], 1, 1, 0)
 
 	// Verify that the client has routes from the primary machine
 	srs1 = subRouter1.MustStatus()
@@ -645,7 +645,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
 	assert.Len(t, nodes, 6)
 
 	assertNodeRouteCount(t, nodes[0], 1, 1, 1)
-	assertNodeRouteCount(t, nodes[1], 1, 1, 1)
+	assertNodeRouteCount(t, nodes[1], 1, 1, 0)
 	assertNodeRouteCount(t, nodes[2], 1, 0, 0)
 
 	// Verify that the route is announced from subnet router 1
@@ -737,7 +737,7 @@ func TestHASubnetRouterFailover(t *testing.T) {
 	require.NoError(t, err)
 	assert.Len(t, nodes, 6)
 
-	assertNodeRouteCount(t, nodes[0], 1, 1, 1)
+	assertNodeRouteCount(t, nodes[0], 1, 1, 0)
 	assertNodeRouteCount(t, nodes[1], 1, 1, 1)
 	assertNodeRouteCount(t, nodes[2], 1, 0, 0)
 
@@ -838,7 +838,7 @@ func TestEnableDisableAutoApprovedRoute(t *testing.T) {
 	command = []string{
 		"tailscale",
 		"set",
-		"--advertise-routes=",
+		`--advertise-routes=`,
 	}
 	_, _, err = subRouter1.Execute(command)
 	require.NoErrorf(t, err, "failed to remove advertised route: %s", err)