diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index c76a4236e..959a8ca72 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/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 diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 5c6aae512..045986aed 100644 --- a/cmd/tsidp/depaware.txt +++ b/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+ diff --git a/feature/oauthkey/oauthkey.go b/feature/oauthkey/oauthkey.go index 5834c33be..336340c85 100644 --- a/feature/oauthkey/oauthkey.go +++ b/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 +} diff --git a/feature/oauthkey/oauthkey_test.go b/feature/oauthkey/oauthkey_test.go new file mode 100644 index 000000000..b550d8c2c --- /dev/null +++ b/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) + } + })) +} diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 825a39e34..9ef42400f 100644 --- a/tsnet/depaware.txt +++ b/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+ diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index 14747650f..ea165e932 100644 --- a/tsnet/tsnet.go +++ b/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 diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index f1531d013..838d5f3f5 100644 --- a/tsnet/tsnet_test.go +++ b/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) + } + }) + } +}