Просмотр исходного кода

tsnet: enable node registration via federated identity

Updates: tailscale.com/corp#34148

Signed-off-by: Gesa Stupperich <[email protected]>
Gesa Stupperich 3 месяцев назад
Родитель
Сommit
536188c1b5

+ 2 - 0
cmd/k8s-operator/depaware.txt

@@ -727,9 +727,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
         tailscale.com/feature/buildfeatures                          from tailscale.com/wgengine/magicsock+
         tailscale.com/feature/c2n                                    from tailscale.com/tsnet
         tailscale.com/feature/condlite/expvar                        from tailscale.com/wgengine/magicsock
+        tailscale.com/feature/condregister/identityfederation        from tailscale.com/tsnet
         tailscale.com/feature/condregister/oauthkey                  from tailscale.com/tsnet
         tailscale.com/feature/condregister/portmapper                from tailscale.com/tsnet
         tailscale.com/feature/condregister/useproxy                  from tailscale.com/tsnet
+        tailscale.com/feature/identityfederation                     from tailscale.com/feature/condregister/identityfederation
         tailscale.com/feature/oauthkey                               from tailscale.com/feature/condregister/oauthkey
         tailscale.com/feature/portmapper                             from tailscale.com/feature/condregister/portmapper
         tailscale.com/feature/syspolicy                              from tailscale.com/logpolicy

+ 3 - 1
cmd/tsidp/depaware.txt

@@ -146,9 +146,11 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
         tailscale.com/feature/buildfeatures                          from tailscale.com/wgengine/magicsock+
         tailscale.com/feature/c2n                                    from tailscale.com/tsnet
         tailscale.com/feature/condlite/expvar                        from tailscale.com/wgengine/magicsock
+        tailscale.com/feature/condregister/identityfederation        from tailscale.com/tsnet
         tailscale.com/feature/condregister/oauthkey                  from tailscale.com/tsnet
         tailscale.com/feature/condregister/portmapper                from tailscale.com/tsnet
         tailscale.com/feature/condregister/useproxy                  from tailscale.com/tsnet
+        tailscale.com/feature/identityfederation                     from tailscale.com/feature/condregister/identityfederation
         tailscale.com/feature/oauthkey                               from tailscale.com/feature/condregister/oauthkey
         tailscale.com/feature/portmapper                             from tailscale.com/feature/condregister/portmapper
         tailscale.com/feature/syspolicy                              from tailscale.com/logpolicy
@@ -350,7 +352,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
         golang.org/x/net/ipv6                                        from github.com/prometheus-community/pro-bing+
         golang.org/x/net/proxy                                       from tailscale.com/net/netns
    D    golang.org/x/net/route                                       from tailscale.com/net/netmon+
-        golang.org/x/oauth2                                          from golang.org/x/oauth2/clientcredentials
+        golang.org/x/oauth2                                          from golang.org/x/oauth2/clientcredentials+
         golang.org/x/oauth2/clientcredentials                        from tailscale.com/feature/oauthkey
         golang.org/x/oauth2/internal                                 from golang.org/x/oauth2+
         golang.org/x/sync/errgroup                                   from github.com/mdlayher/socket+

+ 44 - 37
feature/oauthkey/oauthkey.go

@@ -33,54 +33,22 @@ func init() {
 // false. The "baseURL" defaults to https://api.tailscale.com.
 // The passed in tags are required, and must be non-empty. These will be
 // set on the authkey generated by the OAuth2 dance.
-func resolveAuthKey(ctx context.Context, v string, tags []string) (string, error) {
-	if !strings.HasPrefix(v, "tskey-client-") {
-		return v, nil
+func resolveAuthKey(ctx context.Context, clientSecret string, tags []string) (string, error) {
+	if !strings.HasPrefix(clientSecret, "tskey-client-") {
+		return clientSecret, nil
 	}
 	if len(tags) == 0 {
 		return "", errors.New("oauth authkeys require --advertise-tags")
 	}
 
-	clientSecret, named, _ := strings.Cut(v, "?")
-	attrs, err := url.ParseQuery(named)
-	if err != nil {
-		return "", err
-	}
-	for k := range attrs {
-		switch k {
-		case "ephemeral", "preauthorized", "baseURL":
-		default:
-			return "", fmt.Errorf("unknown attribute %q", k)
-		}
-	}
-	getBool := func(name string, def bool) (bool, error) {
-		v := attrs.Get(name)
-		if v == "" {
-			return def, nil
-		}
-		ret, err := strconv.ParseBool(v)
-		if err != nil {
-			return false, fmt.Errorf("invalid attribute boolean attribute %s value %q", name, v)
-		}
-		return ret, nil
-	}
-	ephemeral, err := getBool("ephemeral", true)
-	if err != nil {
-		return "", err
-	}
-	preauth, err := getBool("preauthorized", false)
+	strippedSecret, ephemeral, preauth, baseURL, err := parseOptionalAttributes(clientSecret)
 	if err != nil {
 		return "", err
 	}
 
-	baseURL := "https://api.tailscale.com"
-	if v := attrs.Get("baseURL"); v != "" {
-		baseURL = v
-	}
-
 	credentials := clientcredentials.Config{
 		ClientID:     "some-client-id", // ignored
-		ClientSecret: clientSecret,
+		ClientSecret: strippedSecret,
 		TokenURL:     baseURL + "/api/v2/oauth/token",
 	}
 
@@ -106,3 +74,42 @@ func resolveAuthKey(ctx context.Context, v string, tags []string) (string, error
 	}
 	return authkey, nil
 }
+
+func parseOptionalAttributes(clientSecret string) (strippedSecret string, ephemeral bool, preauth bool, baseURL string, err error) {
+	strippedSecret, named, _ := strings.Cut(clientSecret, "?")
+	attrs, err := url.ParseQuery(named)
+	if err != nil {
+		return "", false, false, "", err
+	}
+	for k := range attrs {
+		switch k {
+		case "ephemeral", "preauthorized", "baseURL":
+		default:
+			return "", false, false, "", fmt.Errorf("unknown attribute %q", k)
+		}
+	}
+	getBool := func(name string, def bool) (bool, error) {
+		v := attrs.Get(name)
+		if v == "" {
+			return def, nil
+		}
+		ret, err := strconv.ParseBool(v)
+		if err != nil {
+			return false, fmt.Errorf("invalid attribute boolean attribute %s value %q", name, v)
+		}
+		return ret, nil
+	}
+	ephemeral, err = getBool("ephemeral", true)
+	if err != nil {
+		return "", false, false, "", err
+	}
+	preauth, err = getBool("preauthorized", false)
+	if err != nil {
+		return "", false, false, "", err
+	}
+	baseURL = "https://api.tailscale.com"
+	if v := attrs.Get("baseURL"); v != "" {
+		baseURL = v
+	}
+	return strippedSecret, ephemeral, preauth, baseURL, nil
+}

+ 187 - 0
feature/oauthkey/oauthkey_test.go

@@ -0,0 +1,187 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package oauthkey
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestResolveAuthKey(t *testing.T) {
+	tests := []struct {
+		name        string
+		clientID    string
+		tags        []string
+		wantAuthKey string
+		wantErr     bool
+	}{
+		{
+			name:        "keys without client secret prefix pass through unchanged",
+			clientID:    "tskey-auth-regular",
+			tags:        []string{"tag:test"},
+			wantAuthKey: "tskey-auth-regular",
+			wantErr:     false,
+		},
+		{
+			name:        "client secret without advertised tags",
+			clientID:    "tskey-client-abc",
+			tags:        nil,
+			wantAuthKey: "",
+			wantErr:     true,
+		},
+		{
+			name:        "client secret with default attributes",
+			clientID:    "tskey-client-abc",
+			tags:        []string{"tag:test"},
+			wantAuthKey: "tskey-auth-xyz",
+			wantErr:     false,
+		},
+		{
+			name:        "client secret with custom attributes",
+			clientID:    "tskey-client-abc?ephemeral=false&preauthorized=true",
+			tags:        []string{"tag:test"},
+			wantAuthKey: "tskey-auth-xyz",
+			wantErr:     false,
+		},
+		{
+			name:        "client secret with unknown attribute",
+			clientID:    "tskey-client-abc?unknown=value",
+			tags:        []string{"tag:test"},
+			wantAuthKey: "",
+			wantErr:     true,
+		},
+		{
+			name:        "oauth client secret with invalid attribute value",
+			clientID:    "tskey-client-abc?ephemeral=invalid",
+			tags:        []string{"tag:test"},
+			wantAuthKey: "",
+			wantErr:     true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srv := mockControlServer(t)
+			defer srv.Close()
+
+			// resolveAuthKey reads custom control plane URLs off the baseURL attribute
+			// on the client secret string. Therefore, append the baseURL attribute with
+			// the mock control server URL to any client secret in order to hit the mock
+			// server instead of the default control API.
+			if strings.HasPrefix(tt.clientID, "tskey-client") {
+				if !strings.Contains(tt.clientID, "?") {
+					tt.clientID += "?baseURL=" + srv.URL
+				} else {
+					tt.clientID += "&baseURL=" + srv.URL
+				}
+			}
+
+			got, err := resolveAuthKey(context.Background(), tt.clientID, tt.tags)
+
+			if tt.wantErr {
+				if err == nil {
+					t.Error("want error but got none")
+					return
+				}
+				return
+			}
+
+			if err != nil {
+				t.Errorf("want no error, got %q", err)
+				return
+			}
+
+			if got != tt.wantAuthKey {
+				t.Errorf("want authKey = %q, got %q", tt.wantAuthKey, got)
+			}
+		})
+	}
+}
+
+func TestResolveAuthKeyAttributes(t *testing.T) {
+	tests := []struct {
+		name          string
+		clientSecret  string
+		wantEphemeral bool
+		wantPreauth   bool
+		wantBaseURL   string
+	}{
+		{
+			name:          "default values",
+			clientSecret:  "tskey-client-abc",
+			wantEphemeral: true,
+			wantPreauth:   false,
+			wantBaseURL:   "https://api.tailscale.com",
+		},
+		{
+			name:          "ephemeral=false",
+			clientSecret:  "tskey-client-abc?ephemeral=false",
+			wantEphemeral: false,
+			wantPreauth:   false,
+			wantBaseURL:   "https://api.tailscale.com",
+		},
+		{
+			name:          "preauthorized=true",
+			clientSecret:  "tskey-client-abc?preauthorized=true",
+			wantEphemeral: true,
+			wantPreauth:   true,
+			wantBaseURL:   "https://api.tailscale.com",
+		},
+		{
+			name:          "baseURL=https://api.example.com",
+			clientSecret:  "tskey-client-abc?baseURL=https://api.example.com",
+			wantEphemeral: true,
+			wantPreauth:   false,
+			wantBaseURL:   "https://api.example.com",
+		},
+		{
+			name:          "all custom values",
+			clientSecret:  "tskey-client-abc?ephemeral=false&preauthorized=true&baseURL=https://api.example.com",
+			wantEphemeral: false,
+			wantPreauth:   true,
+			wantBaseURL:   "https://api.example.com",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			strippedSecret, ephemeral, preauth, baseURL, err := parseOptionalAttributes(tt.clientSecret)
+			if err != nil {
+				t.Fatalf("want no error, got %q", err)
+			}
+			if strippedSecret != "tskey-client-abc" {
+				t.Errorf("want tskey-client-abc, got %q", strippedSecret)
+			}
+			if ephemeral != tt.wantEphemeral {
+				t.Errorf("want ephemeral = %v, got %v", tt.wantEphemeral, ephemeral)
+			}
+			if preauth != tt.wantPreauth {
+				t.Errorf("want preauth = %v, got %v", tt.wantPreauth, preauth)
+			}
+			if baseURL != tt.wantBaseURL {
+				t.Errorf("want baseURL = %v, got %v", tt.wantBaseURL, baseURL)
+			}
+		})
+	}
+}
+
+func mockControlServer(t *testing.T) *httptest.Server {
+	t.Helper()
+
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch {
+		case strings.Contains(r.URL.Path, "/api/v2/oauth/token"):
+			w.Header().Set("Content-Type", "application/json")
+			w.Write([]byte(`{"access_token":"access-123","token_type":"Bearer","expires_in":3600}`))
+		case strings.Contains(r.URL.Path, "/api/v2/tailnet") && strings.Contains(r.URL.Path, "/keys"):
+			w.Header().Set("Content-Type", "application/json")
+			w.Write([]byte(`{"key":"tskey-auth-xyz"}`))
+		default:
+			w.WriteHeader(http.StatusNotFound)
+		}
+	}))
+}

+ 3 - 1
tsnet/depaware.txt

@@ -142,9 +142,11 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
         tailscale.com/feature/buildfeatures                          from tailscale.com/wgengine/magicsock+
         tailscale.com/feature/c2n                                    from tailscale.com/tsnet
         tailscale.com/feature/condlite/expvar                        from tailscale.com/wgengine/magicsock
+        tailscale.com/feature/condregister/identityfederation        from tailscale.com/tsnet
         tailscale.com/feature/condregister/oauthkey                  from tailscale.com/tsnet
         tailscale.com/feature/condregister/portmapper                from tailscale.com/tsnet
         tailscale.com/feature/condregister/useproxy                  from tailscale.com/tsnet
+        tailscale.com/feature/identityfederation                     from tailscale.com/feature/condregister/identityfederation
         tailscale.com/feature/oauthkey                               from tailscale.com/feature/condregister/oauthkey
         tailscale.com/feature/portmapper                             from tailscale.com/feature/condregister/portmapper
         tailscale.com/feature/syspolicy                              from tailscale.com/logpolicy
@@ -343,7 +345,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
         golang.org/x/net/ipv6                                        from github.com/prometheus-community/pro-bing+
  LDW    golang.org/x/net/proxy                                       from tailscale.com/net/netns
   DI    golang.org/x/net/route                                       from tailscale.com/net/netmon+
-        golang.org/x/oauth2                                          from golang.org/x/oauth2/clientcredentials
+        golang.org/x/oauth2                                          from golang.org/x/oauth2/clientcredentials+
         golang.org/x/oauth2/clientcredentials                        from tailscale.com/feature/oauthkey
         golang.org/x/oauth2/internal                                 from golang.org/x/oauth2+
         golang.org/x/sync/errgroup                                   from github.com/mdlayher/socket+

+ 84 - 8
tsnet/tsnet.go

@@ -30,6 +30,7 @@ import (
 	"tailscale.com/control/controlclient"
 	"tailscale.com/envknob"
 	_ "tailscale.com/feature/c2n"
+	_ "tailscale.com/feature/condregister/identityfederation"
 	_ "tailscale.com/feature/condregister/oauthkey"
 	_ "tailscale.com/feature/condregister/portmapper"
 	_ "tailscale.com/feature/condregister/useproxy"
@@ -115,6 +116,29 @@ type Server struct {
 	// used.
 	AuthKey string
 
+	// ClientSecret, if non-empty, is the OAuth client secret
+	// that will be used to generate authkeys via OAuth. It
+	// will be preferred over the TS_CLIENT_SECRET environment
+	// variable. If the node is already created (from state
+	// previously stored in Store), then this field is not
+	// used.
+	ClientSecret string
+
+	// ClientID, if non-empty, is the client ID used to generate
+	// authkeys via workload identity federation. It will be
+	// preferred over the TS_CLIENT_ID environment variable.
+	// If the node is already created (from state previously
+	// stored in Store), then this field is not used.
+	ClientID string
+
+	// IDToken, if non-empty, is the ID token from the identity
+	// provider to exchange with the control server for workload
+	// identity federation. It will be preferred over the
+	// TS_ID_TOKEN environment variable. If the node is already
+	// created (from state previously stored in Store), then this
+	// field is not used.
+	IDToken string
+
 	// ControlURL optionally specifies the coordination server URL.
 	// If empty, the Tailscale default is used.
 	ControlURL string
@@ -517,6 +541,27 @@ func (s *Server) getAuthKey() string {
 	return os.Getenv("TS_AUTH_KEY")
 }
 
+func (s *Server) getClientSecret() string {
+	if v := s.ClientSecret; v != "" {
+		return v
+	}
+	return os.Getenv("TS_CLIENT_SECRET")
+}
+
+func (s *Server) getClientID() string {
+	if v := s.ClientID; v != "" {
+		return v
+	}
+	return os.Getenv("TS_CLIENT_ID")
+}
+
+func (s *Server) getIDToken() string {
+	if v := s.IDToken; v != "" {
+		return v
+	}
+	return os.Getenv("TS_ID_TOKEN")
+}
+
 func (s *Server) start() (reterr error) {
 	var closePool closeOnErrorPool
 	defer closePool.closeAllIfError(&reterr)
@@ -684,14 +729,9 @@ func (s *Server) start() (reterr error) {
 	prefs.ControlURL = s.ControlURL
 	prefs.RunWebClient = s.RunWebClient
 	prefs.AdvertiseTags = s.AdvertiseTags
-	authKey := s.getAuthKey()
-	// Try to use an OAuth secret to generate an auth key if that functionality
-	// is available.
-	if f, ok := tailscale.HookResolveAuthKey.GetOk(); ok {
-		authKey, err = f(s.shutdownCtx, s.getAuthKey(), prefs.AdvertiseTags)
-		if err != nil {
-			return fmt.Errorf("resolving auth key: %w", err)
-		}
+	authKey, err := s.resolveAuthKey()
+	if err != nil {
+		return fmt.Errorf("error resolving auth key: %w", err)
 	}
 	err = lb.Start(ipn.Options{
 		UpdatePrefs: prefs,
@@ -738,6 +778,42 @@ func (s *Server) start() (reterr error) {
 	return nil
 }
 
+func (s *Server) resolveAuthKey() (string, error) {
+	authKey := s.getAuthKey()
+	var err error
+	// Try to use an OAuth secret to generate an auth key if that functionality
+	// is available.
+	resolveViaOAuth, oauthOk := tailscale.HookResolveAuthKey.GetOk()
+	if oauthOk {
+		clientSecret := authKey
+		if authKey == "" {
+			clientSecret = s.getClientSecret()
+		}
+		authKey, err = resolveViaOAuth(s.shutdownCtx, clientSecret, s.AdvertiseTags)
+		if err != nil {
+			return "", err
+		}
+	}
+	// Try to resolve the auth key via workload identity federation if that functionality
+	// is available and no auth key is yet determined.
+	resolveViaWIF, wifOk := tailscale.HookResolveAuthKeyViaWIF.GetOk()
+	if wifOk && authKey == "" {
+		clientID := s.getClientID()
+		idToken := s.getIDToken()
+		if clientID != "" && idToken == "" {
+			return "", fmt.Errorf("client ID for workload identity federation found, but ID token is empty")
+		}
+		if clientID == "" && idToken != "" {
+			return "", fmt.Errorf("ID token for workload identity federation found, but client ID is empty")
+		}
+		authKey, err = resolveViaWIF(s.shutdownCtx, s.ControlURL, clientID, idToken, s.AdvertiseTags)
+		if err != nil {
+			return "", err
+		}
+	}
+	return authKey, nil
+}
+
 func (s *Server) startLogger(closePool *closeOnErrorPool, health *health.Tracker, tsLogf logger.Logf) error {
 	if testenv.InTest() {
 		return nil

+ 199 - 0
tsnet/tsnet_test.go

@@ -38,6 +38,7 @@ import (
 	"golang.org/x/net/proxy"
 	"tailscale.com/client/local"
 	"tailscale.com/cmd/testwrapper/flakytest"
+	"tailscale.com/internal/client/tailscale"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn/store/mem"
 	"tailscale.com/net/netns"
@@ -1393,3 +1394,201 @@ func TestDeps(t *testing.T) {
 		},
 	}.Check(t)
 }
+
+func TestResolveAuthKey(t *testing.T) {
+	tests := []struct {
+		name            string
+		authKey         string
+		clientSecret    string
+		clientID        string
+		idToken         string
+		oauthAvailable  bool
+		wifAvailable    bool
+		resolveViaOAuth func(ctx context.Context, clientSecret string, tags []string) (string, error)
+		resolveViaWIF   func(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error)
+		wantAuthKey     string
+		wantErr         bool
+		wantErrContains string
+	}{
+		{
+			name:           "successful resolution via OAuth client secret",
+			clientSecret:   "tskey-client-secret-123",
+			oauthAvailable: true,
+			resolveViaOAuth: func(ctx context.Context, clientSecret string, tags []string) (string, error) {
+				if clientSecret != "tskey-client-secret-123" {
+					return "", fmt.Errorf("unexpected client secret: %s", clientSecret)
+				}
+				return "tskey-auth-via-oauth", nil
+			},
+			wantAuthKey:     "tskey-auth-via-oauth",
+			wantErrContains: "",
+		},
+		{
+			name:           "failing resolution via OAuth client secret",
+			clientSecret:   "tskey-client-secret-123",
+			oauthAvailable: true,
+			resolveViaOAuth: func(ctx context.Context, clientSecret string, tags []string) (string, error) {
+				return "", fmt.Errorf("resolution failed")
+			},
+			wantErrContains: "resolution failed",
+		},
+		{
+			name:         "successful resolution via federated ID token",
+			clientID:     "client-id-123",
+			idToken:      "id-token-456",
+			wifAvailable: true,
+			resolveViaWIF: func(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error) {
+				if clientID != "client-id-123" {
+					return "", fmt.Errorf("unexpected client ID: %s", clientID)
+				}
+				if idToken != "id-token-456" {
+					return "", fmt.Errorf("unexpected ID token: %s", idToken)
+				}
+				return "tskey-auth-via-wif", nil
+			},
+			wantAuthKey:     "tskey-auth-via-wif",
+			wantErrContains: "",
+		},
+		{
+			name:         "failing resolution via federated ID token",
+			clientID:     "client-id-123",
+			idToken:      "id-token-456",
+			wifAvailable: true,
+			resolveViaWIF: func(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error) {
+				return "", fmt.Errorf("resolution failed")
+			},
+			wantErrContains: "resolution failed",
+		},
+		{
+			name:         "empty client ID",
+			clientID:     "",
+			idToken:      "id-token-456",
+			wifAvailable: true,
+			resolveViaWIF: func(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error) {
+				return "", fmt.Errorf("should not be called")
+			},
+			wantErrContains: "empty",
+		},
+		{
+			name:         "empty ID token",
+			clientID:     "client-id-123",
+			idToken:      "",
+			wifAvailable: true,
+			resolveViaWIF: func(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error) {
+				return "", fmt.Errorf("should not be called")
+			},
+			wantErrContains: "empty",
+		},
+		{
+			name:           "workload identity resolution skipped if resolution via OAuth token succeeds",
+			clientSecret:   "tskey-client-secret-123",
+			oauthAvailable: true,
+			resolveViaOAuth: func(ctx context.Context, clientSecret string, tags []string) (string, error) {
+				if clientSecret != "tskey-client-secret-123" {
+					return "", fmt.Errorf("unexpected client secret: %s", clientSecret)
+				}
+				return "tskey-auth-via-oauth", nil
+			},
+			wifAvailable: true,
+			resolveViaWIF: func(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error) {
+				return "", fmt.Errorf("should not be called")
+			},
+			wantAuthKey:     "tskey-auth-via-oauth",
+			wantErrContains: "",
+		},
+		{
+			name:           "workload identity resolution skipped if resolution via OAuth token fails",
+			clientID:       "tskey-client-id-123",
+			idToken:        "",
+			oauthAvailable: true,
+			resolveViaOAuth: func(ctx context.Context, clientSecret string, tags []string) (string, error) {
+				return "", fmt.Errorf("resolution failed")
+			},
+			wifAvailable: true,
+			resolveViaWIF: func(ctx context.Context, baseURL, clientID, idToken string, tags []string) (string, error) {
+				return "", fmt.Errorf("should not be called")
+			},
+			wantErrContains: "failed",
+		},
+		{
+			name:            "authkey set and no resolution available",
+			authKey:         "tskey-auth-123",
+			oauthAvailable:  false,
+			wifAvailable:    false,
+			wantAuthKey:     "tskey-auth-123",
+			wantErrContains: "",
+		},
+		{
+			name:            "no authkey set and no resolution available",
+			oauthAvailable:  false,
+			wifAvailable:    false,
+			wantAuthKey:     "",
+			wantErrContains: "",
+		},
+		{
+			name:           "authkey is client secret and resolution via OAuth client secret succeeds",
+			authKey:        "tskey-client-secret-123",
+			oauthAvailable: true,
+			resolveViaOAuth: func(ctx context.Context, clientSecret string, tags []string) (string, error) {
+				if clientSecret != "tskey-client-secret-123" {
+					return "", fmt.Errorf("unexpected client secret: %s", clientSecret)
+				}
+				return "tskey-auth-via-oauth", nil
+			},
+			wantAuthKey:     "tskey-auth-via-oauth",
+			wantErrContains: "",
+		},
+		{
+			name:           "authkey is client secret but resolution via OAuth client secret fails",
+			authKey:        "tskey-client-secret-123",
+			oauthAvailable: true,
+			resolveViaOAuth: func(ctx context.Context, clientSecret string, tags []string) (string, error) {
+				return "", fmt.Errorf("resolution failed")
+			},
+			wantErrContains: "resolution failed",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.oauthAvailable {
+				t.Cleanup(tailscale.HookResolveAuthKey.SetForTest(tt.resolveViaOAuth))
+			}
+
+			if tt.wifAvailable {
+				t.Cleanup(tailscale.HookResolveAuthKeyViaWIF.SetForTest(tt.resolveViaWIF))
+			}
+
+			s := &Server{
+				AuthKey:      tt.authKey,
+				ClientSecret: tt.clientSecret,
+				ClientID:     tt.clientID,
+				IDToken:      tt.idToken,
+				ControlURL:   "https://control.example.com",
+			}
+			s.shutdownCtx = context.Background()
+
+			gotAuthKey, err := s.resolveAuthKey()
+
+			if tt.wantErrContains != "" {
+				if err == nil {
+					t.Errorf("expected error but got none")
+					return
+				}
+				if !strings.Contains(err.Error(), tt.wantErrContains) {
+					t.Errorf("expected error containing %q but got error: %v", tt.wantErrContains, err)
+				}
+				return
+			}
+
+			if err != nil {
+				t.Errorf("resolveAuthKey expected no error but got error: %v", err)
+				return
+			}
+
+			if gotAuthKey != tt.wantAuthKey {
+				t.Errorf("resolveAuthKey() = %q, want %q", gotAuthKey, tt.wantAuthKey)
+			}
+		})
+	}
+}