From 434a9cecbdac1bcb2326639743d840018afe72e3 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Tue, 30 Jun 2026 16:32:18 -0400 Subject: [PATCH 01/10] Add loginToken and parseLoginResponse for ext token support The basic and OAuth login paths returned a v3 Norman Token directly. With session tokens about to migrate to ext.cattle.io/v1 server-side, the CLI needs to accept both response shapes from /v1-public/login. A parser detects the shape via apiVersion and kind, then unmarshals into a single internal type that carries the bearer token, expires-at, and user id. --- cmd/token.go | 54 ++++++++++++++++++++++++++++ cmd/token_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 cmd/token.go create mode 100644 cmd/token_test.go diff --git a/cmd/token.go b/cmd/token.go new file mode 100644 index 000000000..d778e33ac --- /dev/null +++ b/cmd/token.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + extv1 "github.com/rancher/rancher/pkg/apis/ext.cattle.io/v1" +) + +// loginToken holds the fields extracted from a basic/OAuth login response, +// normalizing v3 Norman tokens and ext.cattle.io/v1 Tokens to a single type. +type loginToken struct { + BearerToken string + ExpiresAt string + UserID string +} + +// parseLoginResponse detects whether body is a v3 Norman token or an +// ext.cattle.io/v1 Token by inspecting apiVersion + kind, and returns a loginToken. +func parseLoginResponse(body []byte) (loginToken, error) { + var hdr struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + } + if err := json.Unmarshal(body, &hdr); err != nil { + return loginToken{}, fmt.Errorf("error unmarshaling login response: %w", err) + } + + if hdr.APIVersion == "ext.cattle.io/v1" && hdr.Kind == "Token" { + var t extv1.Token + if err := json.Unmarshal(body, &t); err != nil { + return loginToken{}, fmt.Errorf("error unmarshaling ext token response: %w", err) + } + return loginToken{ + BearerToken: t.Status.BearerToken, + ExpiresAt: t.Status.ExpiresAt, + UserID: t.Spec.UserID, + }, nil + } + + var t struct { + Token string `json:"token"` + ExpiresAt string `json:"expiresAt"` + UserID string `json:"userId"` + } + if err := json.Unmarshal(body, &t); err != nil { + return loginToken{}, fmt.Errorf("error unmarshaling v3 token response: %w", err) + } + return loginToken{ + BearerToken: t.Token, + ExpiresAt: t.ExpiresAt, + UserID: t.UserID, + }, nil +} diff --git a/cmd/token_test.go b/cmd/token_test.go new file mode 100644 index 000000000..8a1c01ba2 --- /dev/null +++ b/cmd/token_test.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseLoginResponse_V3(t *testing.T) { + t.Parallel() + + expiresAt := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + body := []byte(`{ + "type": "token", + "token": "token-abc:secretxyz", + "expiresAt": "` + expiresAt + `", + "userId": "user-123" + }`) + + got, err := parseLoginResponse(body) + + require.NoError(t, err) + assert.Equal(t, "token-abc:secretxyz", got.BearerToken) + assert.Equal(t, expiresAt, got.ExpiresAt) + assert.Equal(t, "user-123", got.UserID) +} + +func TestParseLoginResponse_Ext(t *testing.T) { + t.Parallel() + + expiresAt := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + body := []byte(`{ + "apiVersion": "ext.cattle.io/v1", + "kind": "Token", + "metadata": {"name": "token-def"}, + "spec": { "userID": "user-456" }, + "status": { + "bearerToken": "ext/token-def:secretabc", + "expiresAt": "` + expiresAt + `" + } + }`) + + got, err := parseLoginResponse(body) + + require.NoError(t, err) + assert.Equal(t, "ext/token-def:secretabc", got.BearerToken) + assert.Equal(t, expiresAt, got.ExpiresAt) + assert.Equal(t, "user-456", got.UserID) +} + +func TestParseLoginResponse_LooksLikeExtButWrongKind(t *testing.T) { + t.Parallel() + + // Stray apiVersion field on a v3 response must not route to ext parser. + body := []byte(`{ + "apiVersion": "management.cattle.io/v3", + "type": "token", + "token": "tok:sec", + "expiresAt": "", + "userId": "u" + }`) + + got, err := parseLoginResponse(body) + + require.NoError(t, err) + assert.Equal(t, "tok:sec", got.BearerToken) + assert.Equal(t, "u", got.UserID) +} + +func TestParseLoginResponse_InvalidJSON(t *testing.T) { + t.Parallel() + + _, err := parseLoginResponse([]byte("not json")) + + require.Error(t, err) + assert.ErrorContains(t, err, "error unmarshaling login response") +} + +func TestParseLoginResponse_ExtInvalidJSON(t *testing.T) { + t.Parallel() + + body := []byte(`{"apiVersion": "ext.cattle.io/v1", "kind": "Token", "spec": "not-an-object"}`) + + _, err := parseLoginResponse(body) + + require.Error(t, err) + assert.ErrorContains(t, err, "error unmarshaling ext token response") +} From 62e7599b1c8ae1339790b1e681e4b10c2d5e065a Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Tue, 30 Jun 2026 16:32:18 -0400 Subject: [PATCH 02/10] Return loginToken from basic and OAuth login paths The basic and OAuth login paths returned a v3 Norman Token, mixing the protocol shape into the cred-building code. These paths now return the shared internal type, and the cred builder reads only its bearer token, expires-at, and user id. The SAML path stays on v3 because the corresponding server endpoint stays on v3. --- cmd/kubectl_token.go | 40 +++++++++++++++++++++----------------- cmd/kubectl_token_oauth.go | 16 +++++++-------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/cmd/kubectl_token.go b/cmd/kubectl_token.go index 6b2a44baa..3d8acd659 100644 --- a/cmd/kubectl_token.go +++ b/cmd/kubectl_token.go @@ -339,24 +339,31 @@ func loginAndGenerateCred(client *http.Client, input *LoginInput) (*config.ExecC } input.authProvider = selectedProvider.GetType() - token := managementClient.Token{} - if samlProviders[input.authProvider] { - token, err = samlAuth(client, input, useV1Public) + var token loginToken + switch { + case samlProviders[input.authProvider]: + samlTok, err := samlAuth(client, input, useV1Public) if err != nil { return nil, err } - } else if oauthProviders[input.authProvider] { + token = loginToken{ + BearerToken: samlTok.Token, + ExpiresAt: samlTok.ExpiresAt, + UserID: samlTok.UserID, + } + case oauthProviders[input.authProvider]: tokenPtr, err := oauthAuth(client, input, selectedProvider, useV1Public) if err != nil { return nil, err } token = *tokenPtr - } else { + default: customPrint(fmt.Sprintf("Enter credentials for %s \n", input.authProvider)) - token, err = basicAuth(client, input, useV1Public) + tok, err := basicAuth(client, input, useV1Public) if err != nil { return nil, err } + token = tok } cred := &config.ExecCredential{ @@ -366,7 +373,7 @@ func loginAndGenerateCred(client *http.Client, input *LoginInput) (*config.ExecC }, Status: &config.ExecCredentialStatus{}, } - cred.Status.Token = token.Token + cred.Status.Token = token.BearerToken if token.ExpiresAt == "" { return cred, nil } @@ -377,19 +384,16 @@ func loginAndGenerateCred(client *http.Client, input *LoginInput) (*config.ExecC } cred.Status.ExpirationTimestamp = &config.Time{Time: ts} return cred, nil - } -func basicAuth(client *http.Client, input *LoginInput, useV1Public bool) (managementClient.Token, error) { - token := managementClient.Token{} - +func basicAuth(client *http.Client, input *LoginInput, useV1Public bool) (loginToken, error) { prompt := "Enter username" if input.userID != "" { prompt += " [" + input.userID + "]" } username, err := customPrompt(prompt+": ", true) if err != nil { - return token, err + return loginToken{}, err } if username == "" && input.userID != "" { @@ -398,7 +402,7 @@ func basicAuth(client *http.Client, input *LoginInput, useV1Public bool) (manage password, err := customPrompt("Enter password: ", false) if err != nil { - return token, err + return loginToken{}, err } responseType := "kubeconfig" @@ -413,7 +417,7 @@ func basicAuth(client *http.Client, input *LoginInput, useV1Public bool) (manage "password": password, }) if err != nil { - return token, fmt.Errorf("failed to marshal request body: %w", err) + return loginToken{}, fmt.Errorf("failed to marshal request body: %w", err) } reqURL := fmt.Sprintf(loginURL, input.server) @@ -424,7 +428,7 @@ func basicAuth(client *http.Client, input *LoginInput, useV1Public bool) (manage req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(reqBody)) if err != nil { - return token, fmt.Errorf("error creating request: %w", err) + return loginToken{}, fmt.Errorf("error creating request: %w", err) } resp, respBody, err := doRequest(client, req) @@ -432,12 +436,12 @@ func basicAuth(client *http.Client, input *LoginInput, useV1Public bool) (manage err = fmt.Errorf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) } if err != nil { - return token, fmt.Errorf("error logging user in: %w", err) + return loginToken{}, fmt.Errorf("error logging user in: %w", err) } - err = json.Unmarshal(respBody, &token) + token, err := parseLoginResponse(respBody) if err != nil { - return token, fmt.Errorf("error unmarshaling login response: %w", err) + return loginToken{}, err } return token, nil diff --git a/cmd/kubectl_token_oauth.go b/cmd/kubectl_token_oauth.go index 6c5e6d05d..09c9a54c7 100644 --- a/cmd/kubectl_token_oauth.go +++ b/cmd/kubectl_token_oauth.go @@ -21,7 +21,6 @@ import ( "time" apiv3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" - managementClient "github.com/rancher/rancher/pkg/client/generated/management/v3" "github.com/sirupsen/logrus" "golang.org/x/oauth2" ) @@ -33,7 +32,7 @@ const ( ) // oauthAuth dispatches the OAuth authentication flow based on the auth flow type. -func oauthAuth(client *http.Client, input *LoginInput, provider TypedProvider, useV1Public bool) (*managementClient.Token, error) { +func oauthAuth(client *http.Client, input *LoginInput, provider TypedProvider, useV1Public bool) (*loginToken, error) { if input.authFlow == "" { // The flag has precedence over the env variable. input.authFlow = os.Getenv("CATTLE_OAUTH_AUTH_FLOW") } @@ -77,7 +76,7 @@ func oauthAuthCodeAuth( timeoutAfter time.Duration, useV1Public bool, openBrowser openBrowserFunc, -) (*managementClient.Token, error) { +) (*loginToken, error) { oauthConfig, err := newOauthConfig(provider) if err != nil { return nil, fmt.Errorf("failed to create oauth config: %w", err) @@ -271,7 +270,7 @@ func startCallbackServer(listener net.Listener, expectedState string, resultCh c } // oauthDeviceCodeAuth implements the device code flow for OAuth authentication. -func oauthDeviceCodeAuth(client *http.Client, input *LoginInput, provider TypedProvider, useV1Public bool) (*managementClient.Token, error) { +func oauthDeviceCodeAuth(client *http.Client, input *LoginInput, provider TypedProvider, useV1Public bool) (*loginToken, error) { oauthConfig, err := newOauthConfig(provider) if err != nil { return nil, fmt.Errorf("failed to create oauth config: %w", err) @@ -325,7 +324,7 @@ func newOauthConfig(provider TypedProvider) (*oauth2.Config, error) { } // rancherLogin sends the obtained OAuth token to Rancher to exchange it for a Rancher token that can be used for API authentication. -func rancherLogin(client *http.Client, input *LoginInput, oauthToken *oauth2.Token, useV1Public bool) (*managementClient.Token, error) { +func rancherLogin(client *http.Client, input *LoginInput, oauthToken *oauth2.Token, useV1Public bool) (*loginToken, error) { reqURL := fmt.Sprintf(loginURL, input.server) if !useV1Public { providerName := strings.ToLower(strings.TrimSuffix(input.authProvider, "Provider")) @@ -360,11 +359,10 @@ func rancherLogin(client *http.Client, input *LoginInput, oauthToken *oauth2.Tok return nil, err } - token := &managementClient.Token{} - err = json.Unmarshal(respBody, token) + token, err := parseLoginResponse(respBody) if err != nil { - return nil, fmt.Errorf("error unmarshaling login response: %w", err) + return nil, err } - return token, nil + return &token, nil } From 93598c0675e47e55249a49b3b0396cc058f074f8 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Tue, 30 Jun 2026 16:32:18 -0400 Subject: [PATCH 03/10] Cover ext.cattle.io/v1 Token responses in OAuth login tests The OAuth login tests asserted only the v3 token shape and did not exercise the new ext-token branch in the response parser. The existing table-driven test gains an ext-shaped case with the corresponding bearer-token assertion, and a new test exercises the parser plus cred-building pipeline for both shapes with and without an expires-at field. --- cmd/kubectl_token_oauth_test.go | 38 ++++++++--- cmd/kubectl_token_test.go | 109 ++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/cmd/kubectl_token_oauth_test.go b/cmd/kubectl_token_oauth_test.go index 7dd4283b4..bca5a83be 100644 --- a/cmd/kubectl_token_oauth_test.go +++ b/cmd/kubectl_token_oauth_test.go @@ -153,12 +153,13 @@ func TestRancherLogin(t *testing.T) { expiresAt := time.Now().Add(time.Hour).Format(time.RFC3339) tests := []struct { - name string - useV1Public bool - statusCode int - responseBody string - shouldError bool - errorMsg string + name string + useV1Public bool + statusCode int + responseBody string + shouldError bool + errorMsg string + expectedBearer string // when empty, the assertion falls back to expectedToken }{ { name: "successful login with v1-public", @@ -198,6 +199,23 @@ func TestRancherLogin(t *testing.T) { shouldError: true, errorMsg: "error unmarshaling", }, + { + name: "successful login with ext token response", + useV1Public: true, + statusCode: http.StatusCreated, + responseBody: fmt.Sprintf(`{ + "apiVersion": "ext.cattle.io/v1", + "kind": "Token", + "metadata": {"name": "token-xyz"}, + "spec": { "userID": "user-123" }, + "status": { + "bearerToken": "ext/token-xyz:%s", + "expiresAt": "%s" + } + }`, expectedToken, expiresAt), + shouldError: false, + expectedBearer: "ext/token-xyz:" + expectedToken, + }, } for _, tt := range tests { @@ -252,7 +270,11 @@ func TestRancherLogin(t *testing.T) { } else { require.NoError(t, err) assert.NotNil(t, token) - assert.Equal(t, expectedToken, token.Token) + want := tt.expectedBearer + if want == "" { + want = expectedToken + } + assert.Equal(t, want, token.BearerToken) } }) } @@ -320,7 +342,7 @@ func TestOauthDeviceCodeAuth(t *testing.T) { require.NoError(t, err) assert.NotNil(t, token) - assert.Equal(t, "rancher-token-123", token.Token) + assert.Equal(t, "rancher-token-123", token.BearerToken) } func TestOauthAuthCodeAuth(t *testing.T) { diff --git a/cmd/kubectl_token_test.go b/cmd/kubectl_token_test.go index fefa77013..08c8a9326 100644 --- a/cmd/kubectl_token_test.go +++ b/cmd/kubectl_token_test.go @@ -15,6 +15,27 @@ import ( "github.com/urfave/cli/v3" ) +// buildExecCredential replicates the loginToken→ExecCredential conversion in loginAndGenerateCred. +func buildExecCredential(tok loginToken) (*config.ExecCredential, error) { + cred := &config.ExecCredential{ + TypeMeta: config.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &config.ExecCredentialStatus{}, + } + cred.Status.Token = tok.BearerToken + if tok.ExpiresAt == "" { + return cred, nil + } + ts, err := time.Parse(time.RFC3339, tok.ExpiresAt) + if err != nil { + return nil, err + } + cred.Status.ExpirationTimestamp = &config.Time{Time: ts} + return cred, nil +} + func TestGetAuthProviders(t *testing.T) { t.Parallel() @@ -329,6 +350,94 @@ func TestSelectAuthProvider(t *testing.T) { } } +func TestLoginToken_ExtFormat(t *testing.T) { + t.Parallel() + + expiresAt := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + expiresAtStr := expiresAt.Format(time.RFC3339) + + tests := []struct { + name string + responseBody string + wantBearer string + wantExpiresAt bool + wantExpiresTime time.Time + }{ + { + name: "v3 token response", + responseBody: fmt.Sprintf(`{ + "token": "v3-bearer-token", + "expiresAt": "%s", + "type": "token" + }`, expiresAtStr), + wantBearer: "v3-bearer-token", + wantExpiresAt: true, + wantExpiresTime: expiresAt, + }, + { + name: "ext token response", + responseBody: fmt.Sprintf(`{ + "apiVersion": "ext.cattle.io/v1", + "kind": "Token", + "metadata": {"name": "token-abc"}, + "spec": {"userID": "user-456"}, + "status": { + "bearerToken": "ext/token-abc:ext-bearer-value", + "expiresAt": "%s" + } + }`, expiresAtStr), + wantBearer: "ext/token-abc:ext-bearer-value", + wantExpiresAt: true, + wantExpiresTime: expiresAt, + }, + { + name: "v3 token response without expiresAt", + responseBody: `{ + "token": "v3-no-expiry-token", + "type": "token" + }`, + wantBearer: "v3-no-expiry-token", + wantExpiresAt: false, + }, + { + name: "ext token response without expiresAt", + responseBody: `{ + "apiVersion": "ext.cattle.io/v1", + "kind": "Token", + "metadata": {"name": "token-noexp"}, + "spec": {"userID": "user-789"}, + "status": { + "bearerToken": "ext/token-noexp:no-expiry-value" + } + }`, + wantBearer: "ext/token-noexp:no-expiry-value", + wantExpiresAt: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tok, err := parseLoginResponse([]byte(tt.responseBody)) + require.NoError(t, err) + assert.Equal(t, tt.wantBearer, tok.BearerToken) + + cred, err := buildExecCredential(tok) + require.NoError(t, err) + require.NotNil(t, cred) + assert.Equal(t, tt.wantBearer, cred.Status.Token) + + if tt.wantExpiresAt { + require.NotNil(t, cred.Status.ExpirationTimestamp) + assert.True(t, tt.wantExpiresTime.Equal(cred.Status.ExpirationTimestamp.Time)) + } else { + assert.Nil(t, cred.Status.ExpirationTimestamp) + } + }) + } +} + func TestCacheCredential(t *testing.T) { configDir := t.TempDir() From add98f92e0ccfab89f2dd29771c15ed48cffd33a Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Tue, 30 Jun 2026 16:32:18 -0400 Subject: [PATCH 04/10] Run the OAuth login test subtests in parallel --- cmd/kubectl_token_oauth_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/kubectl_token_oauth_test.go b/cmd/kubectl_token_oauth_test.go index bca5a83be..9ab745e0d 100644 --- a/cmd/kubectl_token_oauth_test.go +++ b/cmd/kubectl_token_oauth_test.go @@ -220,6 +220,8 @@ func TestRancherLogin(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) From 16fd614cc9412cacd341d689465e6a56678bf915 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Tue, 30 Jun 2026 16:32:18 -0400 Subject: [PATCH 05/10] Add ext.cattle.io/v1 token lookup with v3 to ext fallback Token lookups hit only the v3 Management API. Once session tokens start being issued by the ext.cattle.io/v1 API, those lookups would 404. An HTTP helper now fetches tokens from the ext.cattle.io/v1 API. The user-id resolution and token-validation helpers try v3 first and fall back to the ext API; tokens whose id carries the ext/ prefix skip the v3 attempt entirely. Test stubs use function types so the mocks stay minimal. --- cmd/kubectl.go | 4 +- cmd/token.go | 107 ++++++++++++++++++++++ cmd/token_test.go | 221 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 2 deletions(-) diff --git a/cmd/kubectl.go b/cmd/kubectl.go index 783312d3e..d4131dbcb 100644 --- a/cmd/kubectl.go +++ b/cmd/kubectl.go @@ -70,7 +70,7 @@ func runKubectl(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - isTokenValid, err = validateToken(tokenID, c.ManagementClient.Token) + isTokenValid, err = validateTokenV3(tokenID, c.ManagementClient.Token) if err != nil { return err } @@ -138,7 +138,7 @@ func extractKubeconfigTokenID(kubeconfig api.Config) (string, error) { return parts[0], nil } -func validateToken(tokenID string, tokenClient client.TokenOperations) (bool, error) { +func validateTokenV3(tokenID string, tokenClient client.TokenOperations) (bool, error) { token, err := tokenClient.ByID(tokenID) if err != nil { if !clientbase.IsNotFound(err) { diff --git a/cmd/token.go b/cmd/token.go index d778e33ac..73a56c648 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -1,10 +1,15 @@ package cmd import ( + "context" "encoding/json" "fmt" + "net/http" + "strings" + "github.com/rancher/norman/clientbase" extv1 "github.com/rancher/rancher/pkg/apis/ext.cattle.io/v1" + managementClient "github.com/rancher/rancher/pkg/client/generated/management/v3" ) // loginToken holds the fields extracted from a basic/OAuth login response, @@ -52,3 +57,105 @@ func parseLoginResponse(body []byte) (loginToken, error) { UserID: t.UserID, }, nil } + +// tokenByIDFunc looks up a v3 Norman token by id (e.g. managementClient.TokenOperations.ByID). +type tokenByIDFunc func(id string) (*managementClient.Token, error) + +// extTokenGetterFunc retrieves an ext.cattle.io/v1 Token by name. +type extTokenGetterFunc func(ctx context.Context, id string) (*extv1.Token, error) + +// extTokenIDPrefix is the prefix Rancher prepends to ext-issued token bearer strings +// ("ext/:" per pkg/ext/stores/tokens/tokens.go:783 in rancher/rancher). +// When the CLI's stored access key carries this prefix, the id is an ext token and v3 +// lookups must be skipped because they will always 404. +const extTokenIDPrefix = "ext/" + +// getExtToken fetches a token from the ext.cattle.io/v1 API. +// baseURL is the Rancher server root without the trailing /v3 (use serverConfig.EnvironmentURL()). +// Returns a *clientbase.APIError with StatusCode 404 when the token is not found. +// +// IMPORTANT: do not wrap the returned *clientbase.APIError with fmt.Errorf("%w", ...) — +// clientbase.IsNotFound uses a direct type assertion (err.(*APIError)), not errors.As, +// so any wrap will make IsNotFound return false and break the v3-to-ext fallback contract. +// +// Strips an "ext/" prefix from id defensively; callers should already strip but a double strip is harmless. +func getExtToken(ctx context.Context, id, baseURL, bearerToken string, client *http.Client) (*extv1.Token, error) { + id = strings.TrimPrefix(id, extTokenIDPrefix) + u := strings.TrimRight(baseURL, "/") + "/apis/ext.cattle.io/v1/tokens/" + id + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("error creating ext token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+bearerToken) + req.Header.Set("Accept", "application/json") + + resp, body, err := doRequest(client, req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + // doRequest has already drained and closed resp.Body, so build the APIError + // directly rather than calling clientbase.NewAPIError which would read a closed body. + return nil, &clientbase.APIError{ + StatusCode: resp.StatusCode, + URL: u, + Status: resp.Status, + Msg: fmt.Sprintf("Bad response statusCode [%d]. Status [%s]. URL [%s]", resp.StatusCode, resp.Status, u), + Body: string(body), + } + } + + var t extv1.Token + if err := json.Unmarshal(body, &t); err != nil { + return nil, fmt.Errorf("error unmarshaling ext token: %w", err) + } + return &t, nil +} + +// getTokenUserID returns the user id associated with the given token id. +// It tries the v3 Management API first and falls back to ext.cattle.io/v1. +// Ids prefixed with "ext/" skip the v3 attempt entirely. The prefix is stripped +// before the ext lookup so callers receive a clean token name. +func getTokenUserID(ctx context.Context, tokenID string, v3ByID tokenByIDFunc, extGetter extTokenGetterFunc) (string, error) { + if !strings.HasPrefix(tokenID, extTokenIDPrefix) { + token, err := v3ByID(tokenID) + if err == nil { + return token.UserID, nil + } + if !clientbase.IsNotFound(err) { + return "", err + } + } + + extToken, err := extGetter(ctx, strings.TrimPrefix(tokenID, extTokenIDPrefix)) + if err != nil { + return "", err + } + return extToken.Spec.UserID, nil +} + +// validateToken reports whether the token with the given id exists and is not expired. +// It tries the v3 Management API first and falls back to ext.cattle.io/v1. +// Ids prefixed with "ext/" skip the v3 attempt entirely. The prefix is stripped +// before the ext lookup so callers receive a clean token name. +func validateToken(ctx context.Context, tokenID string, v3ByID tokenByIDFunc, extGetter extTokenGetterFunc) (bool, error) { + if !strings.HasPrefix(tokenID, extTokenIDPrefix) { + token, err := v3ByID(tokenID) + if err == nil { + return !token.Expired, nil + } + if !clientbase.IsNotFound(err) { + return false, err + } + } + + extToken, err := extGetter(ctx, strings.TrimPrefix(tokenID, extTokenIDPrefix)) + if err != nil { + if clientbase.IsNotFound(err) { + return false, nil + } + return false, err + } + return !extToken.Status.Expired, nil +} diff --git a/cmd/token_test.go b/cmd/token_test.go index 8a1c01ba2..666cf094e 100644 --- a/cmd/token_test.go +++ b/cmd/token_test.go @@ -1,13 +1,24 @@ package cmd import ( + "context" + "fmt" + "net/http" + "net/http/httptest" "testing" "time" + "github.com/rancher/norman/clientbase" + extv1 "github.com/rancher/rancher/pkg/apis/ext.cattle.io/v1" + managementClient "github.com/rancher/rancher/pkg/client/generated/management/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func newNotFound() error { + return &clientbase.APIError{StatusCode: http.StatusNotFound, Status: "404 Not Found", Msg: "not found"} +} + func TestParseLoginResponse_V3(t *testing.T) { t.Parallel() @@ -88,3 +99,213 @@ func TestParseLoginResponse_ExtInvalidJSON(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "error unmarshaling ext token response") } + +func TestGetExtToken_Success(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/apis/ext.cattle.io/v1/tokens/token-abc", r.URL.Path) + assert.Equal(t, "Bearer ext/token-abc:secret", r.Header.Get("Authorization")) + fmt.Fprint(w, `{ + "apiVersion": "ext.cattle.io/v1", + "kind": "Token", + "metadata": {"name": "token-abc"}, + "spec": {"userID": "user-789"}, + "status": {"expired": false, "expiresAt": "2099-01-01T00:00:00Z"} + }`) + })) + t.Cleanup(server.Close) + + token, err := getExtToken(context.Background(), "token-abc", server.URL, "ext/token-abc:secret", server.Client()) + + require.NoError(t, err) + assert.Equal(t, "user-789", token.Spec.UserID) + assert.False(t, token.Status.Expired) +} + +func TestGetExtToken_StripsExtPrefix(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Caller passed "ext/token-abc" as id; URL must not contain the slash-prefix. + assert.Equal(t, "/apis/ext.cattle.io/v1/tokens/token-abc", r.URL.Path) + fmt.Fprint(w, `{"apiVersion":"ext.cattle.io/v1","kind":"Token","spec":{"userID":"u"},"status":{"expired":false}}`) + })) + t.Cleanup(server.Close) + + _, err := getExtToken(context.Background(), "ext/token-abc", server.URL, "ext/token-abc:secret", server.Client()) + require.NoError(t, err) +} + +func TestGetExtToken_NotFound(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + _, err := getExtToken(context.Background(), "token-abc", server.URL, "token-abc:secret", server.Client()) + + require.Error(t, err) + assert.True(t, clientbase.IsNotFound(err), "expected clientbase.IsNotFound to be true") +} + +func TestGetExtToken_ServerError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + _, err := getExtToken(context.Background(), "token-abc", server.URL, "token-abc:secret", server.Client()) + + require.Error(t, err) + assert.ErrorContains(t, err, "500") +} + +func TestValidateToken_V3Valid(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { + return &managementClient.Token{Expired: false}, nil + } + ext := func(_ context.Context, _ string) (*extv1.Token, error) { + t.Fatal("ext getter must not be called when v3 succeeds") + return nil, nil + } + + ok, err := validateToken(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.True(t, ok) +} + +func TestValidateToken_V3Expired(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { + return &managementClient.Token{Expired: true}, nil + } + ext := func(_ context.Context, _ string) (*extv1.Token, error) { + t.Fatal("ext getter must not be called when v3 succeeds") + return nil, nil + } + + ok, err := validateToken(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateToken_V3NotFound_ExtValid(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } + ext := func(_ context.Context, id string) (*extv1.Token, error) { + return &extv1.Token{Status: extv1.TokenStatus{Expired: false}}, nil + } + + ok, err := validateToken(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.True(t, ok) +} + +func TestValidateToken_V3NotFound_ExtExpired(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } + ext := func(_ context.Context, id string) (*extv1.Token, error) { + return &extv1.Token{Status: extv1.TokenStatus{Expired: true}}, nil + } + + ok, err := validateToken(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateToken_V3NotFound_ExtNotFound(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } + ext := func(_ context.Context, id string) (*extv1.Token, error) { return nil, newNotFound() } + + ok, err := validateToken(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateToken_ExtPrefixSkipsV3(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { + t.Fatal("v3 getter must not be called for ext-prefixed token id") + return nil, nil + } + ext := func(_ context.Context, id string) (*extv1.Token, error) { + assert.Equal(t, "token-abc", id, "prefix must be stripped before ext lookup") + return &extv1.Token{Status: extv1.TokenStatus{Expired: false}}, nil + } + + ok, err := validateToken(context.Background(), "ext/token-abc", v3, ext) + + require.NoError(t, err) + assert.True(t, ok) +} + +func TestGetTokenUserID_V3(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { + return &managementClient.Token{UserID: "user-v3"}, nil + } + ext := func(_ context.Context, _ string) (*extv1.Token, error) { + t.Fatal("ext getter must not be called when v3 succeeds") + return nil, nil + } + + uid, err := getTokenUserID(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.Equal(t, "user-v3", uid) +} + +func TestGetTokenUserID_ExtFallback(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } + ext := func(_ context.Context, id string) (*extv1.Token, error) { + tok := &extv1.Token{} + tok.Spec.UserID = "user-ext" + return tok, nil + } + + uid, err := getTokenUserID(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.Equal(t, "user-ext", uid) +} + +func TestGetTokenUserID_ExtPrefixSkipsV3(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { + t.Fatal("v3 getter must not be called for ext-prefixed token id") + return nil, nil + } + ext := func(_ context.Context, id string) (*extv1.Token, error) { + assert.Equal(t, "token-abc", id, "prefix must be stripped before ext lookup") + tok := &extv1.Token{} + tok.Spec.UserID = "user-ext" + return tok, nil + } + + uid, err := getTokenUserID(context.Background(), "ext/token-abc", v3, ext) + + require.NoError(t, err) + assert.Equal(t, "user-ext", uid) +} From 6c01e08638eb2c1e2a1f5990e66ce99f00142c44 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Tue, 30 Jun 2026 16:32:18 -0400 Subject: [PATCH 06/10] Use ext.cattle.io/v1 token lookup with v3 to ext fallback in kubectl command The kubectl command resolved the current user and validated the kubeconfig token by calling the v3 Token API directly, with no fallback for ext-issued tokens. Both lookups now route through the shared helpers that try v3 first and fall back to the ext.cattle.io/v1 API. The local v3-only validation function and its dependent imports are removed. --- cmd/kubectl.go | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/cmd/kubectl.go b/cmd/kubectl.go index d4131dbcb..b4b87f0a5 100644 --- a/cmd/kubectl.go +++ b/cmd/kubectl.go @@ -7,8 +7,7 @@ import ( "os/exec" "strings" - "github.com/rancher/norman/clientbase" - client "github.com/rancher/rancher/pkg/client/generated/management/v3" + extv1 "github.com/rancher/rancher/pkg/apis/ext.cattle.io/v1" "github.com/urfave/cli/v3" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" @@ -53,12 +52,29 @@ func runKubectl(ctx context.Context, cmd *cli.Command) error { } currentToken := currentRancherServer.AccessKey - t, err := c.ManagementClient.Token.ByID(currentToken) + bearerToken := currentRancherServer.AccessKey + ":" + currentRancherServer.SecretKey + + tlsConf, err := getTLSConfig(false, currentRancherServer.CACerts) if err != nil { - return err + return fmt.Errorf("error creating TLS config: %w", err) + } + httpClient, err := newHTTPClient(currentRancherServer, tlsConf) + if err != nil { + return fmt.Errorf("error creating HTTP client: %w", err) + } + baseURL, err := currentRancherServer.EnvironmentURL() + if err != nil { + return fmt.Errorf("error resolving server base URL: %w", err) + } + extGetter := func(ctx context.Context, id string) (*extv1.Token, error) { + return getExtToken(ctx, id, baseURL, bearerToken, httpClient) } + v3ByID := c.ManagementClient.Token.ByID - currentUser := t.UserID + currentUser, err := getTokenUserID(ctx, currentToken, v3ByID, extGetter) + if err != nil { + return err + } kubeConfig, err := getKubeConfigForUser(cmd, currentUser) if err != nil { return err @@ -70,7 +86,7 @@ func runKubectl(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - isTokenValid, err = validateTokenV3(tokenID, c.ManagementClient.Token) + isTokenValid, err = validateToken(ctx, tokenID, v3ByID, extGetter) if err != nil { return err } @@ -138,13 +154,3 @@ func extractKubeconfigTokenID(kubeconfig api.Config) (string, error) { return parts[0], nil } -func validateTokenV3(tokenID string, tokenClient client.TokenOperations) (bool, error) { - token, err := tokenClient.ByID(tokenID) - if err != nil { - if !clientbase.IsNotFound(err) { - return false, err - } - return false, nil - } - return !token.Expired, nil -} From 496738d2ce8047478cb3ed6f6f25661a298a1808 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Tue, 30 Jun 2026 16:32:19 -0400 Subject: [PATCH 07/10] Harden ext token error handling and clarify bearer token construction A stale or deleted v3 token id surfaced a raw 404 from the ext API after fallback. A v3-tokened user with a stale kubeconfig also saw a hard 401 or 403 from the ext authenticator instead of getting the kubeconfig regenerated. The user-id lookup now wraps the underlying error with the failing token id so the failure is debuggable. Token validation treats 401 and 403 from the ext API the same as 404 and falls through to kubeconfig regeneration. Two new comments explain why the bearer token keeps any ext/ prefix and why a second HTTP client is needed alongside Norman's master client. --- cmd/kubectl.go | 4 ++++ cmd/token.go | 18 ++++++++++++++++-- cmd/token_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/cmd/kubectl.go b/cmd/kubectl.go index b4b87f0a5..02ce090b7 100644 --- a/cmd/kubectl.go +++ b/cmd/kubectl.go @@ -52,8 +52,12 @@ func runKubectl(ctx context.Context, cmd *cli.Command) error { } currentToken := currentRancherServer.AccessKey + // bearerToken intentionally keeps any "ext/" prefix on AccessKey because + // the ext API authenticator parses the prefix back out of the Bearer header. bearerToken := currentRancherServer.AccessKey + ":" + currentRancherServer.SecretKey + // Norman's MasterClient does not expose its underlying http.Client, so + // build a parallel one here for the direct ext API call. tlsConf, err := getTLSConfig(false, currentRancherServer.CACerts) if err != nil { return fmt.Errorf("error creating TLS config: %w", err) diff --git a/cmd/token.go b/cmd/token.go index 73a56c648..ed5875c45 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -130,7 +131,7 @@ func getTokenUserID(ctx context.Context, tokenID string, v3ByID tokenByIDFunc, e extToken, err := extGetter(ctx, strings.TrimPrefix(tokenID, extTokenIDPrefix)) if err != nil { - return "", err + return "", fmt.Errorf("error resolving user id for token %q: %w", tokenID, err) } return extToken.Spec.UserID, nil } @@ -152,10 +153,23 @@ func validateToken(ctx context.Context, tokenID string, v3ByID tokenByIDFunc, ex extToken, err := extGetter(ctx, strings.TrimPrefix(tokenID, extTokenIDPrefix)) if err != nil { - if clientbase.IsNotFound(err) { + // 404/401/403 from the ext API are all "could not determine validity": + // the token isn't there, or the bearer format isn't accepted by this + // server. Fall through to kubeconfig regeneration in either case rather + // than surfacing an opaque error to the user. + if clientbase.IsNotFound(err) || isUnauthorized(err) { return false, nil } return false, err } return !extToken.Status.Expired, nil } + +// isUnauthorized reports whether err is a *clientbase.APIError with status 401 or 403. +func isUnauthorized(err error) bool { + var apiErr *clientbase.APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden + } + return false +} diff --git a/cmd/token_test.go b/cmd/token_test.go index 666cf094e..b49a3f890 100644 --- a/cmd/token_test.go +++ b/cmd/token_test.go @@ -257,6 +257,47 @@ func TestValidateToken_ExtPrefixSkipsV3(t *testing.T) { assert.True(t, ok) } +func TestValidateToken_V3NotFound_ExtUnauthorized(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } + ext := func(_ context.Context, _ string) (*extv1.Token, error) { + return nil, &clientbase.APIError{StatusCode: http.StatusUnauthorized, Status: "401 Unauthorized", Msg: "unauthorized"} + } + + ok, err := validateToken(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateToken_V3NotFound_ExtForbidden(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } + ext := func(_ context.Context, _ string) (*extv1.Token, error) { + return nil, &clientbase.APIError{StatusCode: http.StatusForbidden, Status: "403 Forbidden", Msg: "forbidden"} + } + + ok, err := validateToken(context.Background(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateToken_V3NotFound_ExtOtherError(t *testing.T) { + t.Parallel() + + v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } + ext := func(_ context.Context, _ string) (*extv1.Token, error) { + return nil, &clientbase.APIError{StatusCode: http.StatusInternalServerError, Status: "500", Msg: "boom"} + } + + _, err := validateToken(context.Background(), "token-abc", v3, ext) + + require.Error(t, err) +} + func TestGetTokenUserID_V3(t *testing.T) { t.Parallel() From 0db6cd4d73e427e3b56e7d9f30bab599f3aac0e9 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Wed, 8 Jul 2026 11:37:39 -0400 Subject: [PATCH 08/10] Drop trailing blank line in cmd/kubectl.go --- cmd/kubectl.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/kubectl.go b/cmd/kubectl.go index 02ce090b7..807e1f1fe 100644 --- a/cmd/kubectl.go +++ b/cmd/kubectl.go @@ -157,4 +157,3 @@ func extractKubeconfigTokenID(kubeconfig api.Config) (string, error) { return parts[0], nil } - From d410bac24e8772c7735f374fc3dde708259b2ca1 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Thu, 30 Jul 2026 16:11:31 -0400 Subject: [PATCH 09/10] Fix comments --- cmd/token.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/token.go b/cmd/token.go index ed5875c45..2211d4918 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -117,7 +117,7 @@ func getExtToken(ctx context.Context, id, baseURL, bearerToken string, client *h // getTokenUserID returns the user id associated with the given token id. // It tries the v3 Management API first and falls back to ext.cattle.io/v1. // Ids prefixed with "ext/" skip the v3 attempt entirely. The prefix is stripped -// before the ext lookup so callers receive a clean token name. +// before the ext lookup so callees receive a clean token name. func getTokenUserID(ctx context.Context, tokenID string, v3ByID tokenByIDFunc, extGetter extTokenGetterFunc) (string, error) { if !strings.HasPrefix(tokenID, extTokenIDPrefix) { token, err := v3ByID(tokenID) @@ -139,7 +139,7 @@ func getTokenUserID(ctx context.Context, tokenID string, v3ByID tokenByIDFunc, e // validateToken reports whether the token with the given id exists and is not expired. // It tries the v3 Management API first and falls back to ext.cattle.io/v1. // Ids prefixed with "ext/" skip the v3 attempt entirely. The prefix is stripped -// before the ext lookup so callers receive a clean token name. +// before the ext lookup so callees receive a clean token name. func validateToken(ctx context.Context, tokenID string, v3ByID tokenByIDFunc, extGetter extTokenGetterFunc) (bool, error) { if !strings.HasPrefix(tokenID, extTokenIDPrefix) { token, err := v3ByID(tokenID) From c120de3ed1c280e32f3305ed2067a321bc342534 Mon Sep 17 00:00:00 2001 From: Peter Matseykanets Date: Tue, 4 Aug 2026 18:32:49 -0400 Subject: [PATCH 10/10] Address review feedback Switched all context.Background() calls in cmd/token_test.go to t.Context(), and removed underscores from test functions. --- cmd/kubectl_token_test.go | 2 +- cmd/token_test.go | 74 +++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/cmd/kubectl_token_test.go b/cmd/kubectl_token_test.go index 08c8a9326..38db487c5 100644 --- a/cmd/kubectl_token_test.go +++ b/cmd/kubectl_token_test.go @@ -350,7 +350,7 @@ func TestSelectAuthProvider(t *testing.T) { } } -func TestLoginToken_ExtFormat(t *testing.T) { +func TestLoginTokenExtFormat(t *testing.T) { t.Parallel() expiresAt := time.Now().UTC().Add(time.Hour).Truncate(time.Second) diff --git a/cmd/token_test.go b/cmd/token_test.go index b49a3f890..115073f2f 100644 --- a/cmd/token_test.go +++ b/cmd/token_test.go @@ -19,7 +19,7 @@ func newNotFound() error { return &clientbase.APIError{StatusCode: http.StatusNotFound, Status: "404 Not Found", Msg: "not found"} } -func TestParseLoginResponse_V3(t *testing.T) { +func TestParseLoginResponseV3(t *testing.T) { t.Parallel() expiresAt := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) @@ -38,7 +38,7 @@ func TestParseLoginResponse_V3(t *testing.T) { assert.Equal(t, "user-123", got.UserID) } -func TestParseLoginResponse_Ext(t *testing.T) { +func TestParseLoginResponseExt(t *testing.T) { t.Parallel() expiresAt := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) @@ -61,7 +61,7 @@ func TestParseLoginResponse_Ext(t *testing.T) { assert.Equal(t, "user-456", got.UserID) } -func TestParseLoginResponse_LooksLikeExtButWrongKind(t *testing.T) { +func TestParseLoginResponseLooksLikeExtButWrongKind(t *testing.T) { t.Parallel() // Stray apiVersion field on a v3 response must not route to ext parser. @@ -80,7 +80,7 @@ func TestParseLoginResponse_LooksLikeExtButWrongKind(t *testing.T) { assert.Equal(t, "u", got.UserID) } -func TestParseLoginResponse_InvalidJSON(t *testing.T) { +func TestParseLoginResponseInvalidJSON(t *testing.T) { t.Parallel() _, err := parseLoginResponse([]byte("not json")) @@ -89,7 +89,7 @@ func TestParseLoginResponse_InvalidJSON(t *testing.T) { assert.ErrorContains(t, err, "error unmarshaling login response") } -func TestParseLoginResponse_ExtInvalidJSON(t *testing.T) { +func TestParseLoginResponseExtInvalidJSON(t *testing.T) { t.Parallel() body := []byte(`{"apiVersion": "ext.cattle.io/v1", "kind": "Token", "spec": "not-an-object"}`) @@ -100,7 +100,7 @@ func TestParseLoginResponse_ExtInvalidJSON(t *testing.T) { assert.ErrorContains(t, err, "error unmarshaling ext token response") } -func TestGetExtToken_Success(t *testing.T) { +func TestGetExtTokenSuccess(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -116,14 +116,14 @@ func TestGetExtToken_Success(t *testing.T) { })) t.Cleanup(server.Close) - token, err := getExtToken(context.Background(), "token-abc", server.URL, "ext/token-abc:secret", server.Client()) + token, err := getExtToken(t.Context(), "token-abc", server.URL, "ext/token-abc:secret", server.Client()) require.NoError(t, err) assert.Equal(t, "user-789", token.Spec.UserID) assert.False(t, token.Status.Expired) } -func TestGetExtToken_StripsExtPrefix(t *testing.T) { +func TestGetExtTokenStripsExtPrefix(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -133,11 +133,11 @@ func TestGetExtToken_StripsExtPrefix(t *testing.T) { })) t.Cleanup(server.Close) - _, err := getExtToken(context.Background(), "ext/token-abc", server.URL, "ext/token-abc:secret", server.Client()) + _, err := getExtToken(t.Context(), "ext/token-abc", server.URL, "ext/token-abc:secret", server.Client()) require.NoError(t, err) } -func TestGetExtToken_NotFound(t *testing.T) { +func TestGetExtTokenNotFound(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -145,13 +145,13 @@ func TestGetExtToken_NotFound(t *testing.T) { })) t.Cleanup(server.Close) - _, err := getExtToken(context.Background(), "token-abc", server.URL, "token-abc:secret", server.Client()) + _, err := getExtToken(t.Context(), "token-abc", server.URL, "token-abc:secret", server.Client()) require.Error(t, err) assert.True(t, clientbase.IsNotFound(err), "expected clientbase.IsNotFound to be true") } -func TestGetExtToken_ServerError(t *testing.T) { +func TestGetExtTokenServerError(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -159,13 +159,13 @@ func TestGetExtToken_ServerError(t *testing.T) { })) t.Cleanup(server.Close) - _, err := getExtToken(context.Background(), "token-abc", server.URL, "token-abc:secret", server.Client()) + _, err := getExtToken(t.Context(), "token-abc", server.URL, "token-abc:secret", server.Client()) require.Error(t, err) assert.ErrorContains(t, err, "500") } -func TestValidateToken_V3Valid(t *testing.T) { +func TestValidateTokenV3Valid(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { @@ -176,13 +176,13 @@ func TestValidateToken_V3Valid(t *testing.T) { return nil, nil } - ok, err := validateToken(context.Background(), "token-abc", v3, ext) + ok, err := validateToken(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.True(t, ok) } -func TestValidateToken_V3Expired(t *testing.T) { +func TestValidateTokenV3Expired(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { @@ -193,13 +193,13 @@ func TestValidateToken_V3Expired(t *testing.T) { return nil, nil } - ok, err := validateToken(context.Background(), "token-abc", v3, ext) + ok, err := validateToken(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.False(t, ok) } -func TestValidateToken_V3NotFound_ExtValid(t *testing.T) { +func TestValidateTokenV3NotFoundExtValid(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } @@ -207,13 +207,13 @@ func TestValidateToken_V3NotFound_ExtValid(t *testing.T) { return &extv1.Token{Status: extv1.TokenStatus{Expired: false}}, nil } - ok, err := validateToken(context.Background(), "token-abc", v3, ext) + ok, err := validateToken(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.True(t, ok) } -func TestValidateToken_V3NotFound_ExtExpired(t *testing.T) { +func TestValidateTokenV3NotFoundExtExpired(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } @@ -221,25 +221,25 @@ func TestValidateToken_V3NotFound_ExtExpired(t *testing.T) { return &extv1.Token{Status: extv1.TokenStatus{Expired: true}}, nil } - ok, err := validateToken(context.Background(), "token-abc", v3, ext) + ok, err := validateToken(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.False(t, ok) } -func TestValidateToken_V3NotFound_ExtNotFound(t *testing.T) { +func TestValidateTokenV3NotFoundExtNotFound(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } ext := func(_ context.Context, id string) (*extv1.Token, error) { return nil, newNotFound() } - ok, err := validateToken(context.Background(), "token-abc", v3, ext) + ok, err := validateToken(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.False(t, ok) } -func TestValidateToken_ExtPrefixSkipsV3(t *testing.T) { +func TestValidateTokenExtPrefixSkipsV3(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { @@ -251,13 +251,13 @@ func TestValidateToken_ExtPrefixSkipsV3(t *testing.T) { return &extv1.Token{Status: extv1.TokenStatus{Expired: false}}, nil } - ok, err := validateToken(context.Background(), "ext/token-abc", v3, ext) + ok, err := validateToken(t.Context(), "ext/token-abc", v3, ext) require.NoError(t, err) assert.True(t, ok) } -func TestValidateToken_V3NotFound_ExtUnauthorized(t *testing.T) { +func TestValidateTokenV3NotFoundExtUnauthorized(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } @@ -265,13 +265,13 @@ func TestValidateToken_V3NotFound_ExtUnauthorized(t *testing.T) { return nil, &clientbase.APIError{StatusCode: http.StatusUnauthorized, Status: "401 Unauthorized", Msg: "unauthorized"} } - ok, err := validateToken(context.Background(), "token-abc", v3, ext) + ok, err := validateToken(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.False(t, ok) } -func TestValidateToken_V3NotFound_ExtForbidden(t *testing.T) { +func TestValidateTokenV3NotFoundExtForbidden(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } @@ -279,13 +279,13 @@ func TestValidateToken_V3NotFound_ExtForbidden(t *testing.T) { return nil, &clientbase.APIError{StatusCode: http.StatusForbidden, Status: "403 Forbidden", Msg: "forbidden"} } - ok, err := validateToken(context.Background(), "token-abc", v3, ext) + ok, err := validateToken(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.False(t, ok) } -func TestValidateToken_V3NotFound_ExtOtherError(t *testing.T) { +func TestValidateTokenV3NotFoundExtOtherError(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } @@ -293,12 +293,12 @@ func TestValidateToken_V3NotFound_ExtOtherError(t *testing.T) { return nil, &clientbase.APIError{StatusCode: http.StatusInternalServerError, Status: "500", Msg: "boom"} } - _, err := validateToken(context.Background(), "token-abc", v3, ext) + _, err := validateToken(t.Context(), "token-abc", v3, ext) require.Error(t, err) } -func TestGetTokenUserID_V3(t *testing.T) { +func TestGetTokenUserIDV3(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { @@ -309,13 +309,13 @@ func TestGetTokenUserID_V3(t *testing.T) { return nil, nil } - uid, err := getTokenUserID(context.Background(), "token-abc", v3, ext) + uid, err := getTokenUserID(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.Equal(t, "user-v3", uid) } -func TestGetTokenUserID_ExtFallback(t *testing.T) { +func TestGetTokenUserIDExtFallback(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { return nil, newNotFound() } @@ -325,13 +325,13 @@ func TestGetTokenUserID_ExtFallback(t *testing.T) { return tok, nil } - uid, err := getTokenUserID(context.Background(), "token-abc", v3, ext) + uid, err := getTokenUserID(t.Context(), "token-abc", v3, ext) require.NoError(t, err) assert.Equal(t, "user-ext", uid) } -func TestGetTokenUserID_ExtPrefixSkipsV3(t *testing.T) { +func TestGetTokenUserIDExtPrefixSkipsV3(t *testing.T) { t.Parallel() v3 := func(id string) (*managementClient.Token, error) { @@ -345,7 +345,7 @@ func TestGetTokenUserID_ExtPrefixSkipsV3(t *testing.T) { return tok, nil } - uid, err := getTokenUserID(context.Background(), "ext/token-abc", v3, ext) + uid, err := getTokenUserID(t.Context(), "ext/token-abc", v3, ext) require.NoError(t, err) assert.Equal(t, "user-ext", uid)