diff --git a/cmd/kubectl.go b/cmd/kubectl.go index 783312d3e..807e1f1fe 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,33 @@ func runKubectl(ctx context.Context, cmd *cli.Command) error { } currentToken := currentRancherServer.AccessKey - t, err := c.ManagementClient.Token.ByID(currentToken) + // 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 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 +90,7 @@ func runKubectl(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - isTokenValid, err = validateToken(tokenID, c.ManagementClient.Token) + isTokenValid, err = validateToken(ctx, tokenID, v3ByID, extGetter) if err != nil { return err } @@ -137,14 +157,3 @@ func extractKubeconfigTokenID(kubeconfig api.Config) (string, error) { return parts[0], nil } - -func validateToken(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 -} 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 } diff --git a/cmd/kubectl_token_oauth_test.go b/cmd/kubectl_token_oauth_test.go index 7dd4283b4..9ab745e0d 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,10 +199,29 @@ 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 { 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) @@ -252,7 +272,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 +344,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..38db487c5 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 TestLoginTokenExtFormat(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() diff --git a/cmd/token.go b/cmd/token.go new file mode 100644 index 000000000..2211d4918 --- /dev/null +++ b/cmd/token.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "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, +// 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 +} + +// 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 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) + 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 "", fmt.Errorf("error resolving user id for token %q: %w", tokenID, 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 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) + 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 { + // 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 new file mode 100644 index 000000000..115073f2f --- /dev/null +++ b/cmd/token_test.go @@ -0,0 +1,352 @@ +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 TestParseLoginResponseV3(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 TestParseLoginResponseExt(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 TestParseLoginResponseLooksLikeExtButWrongKind(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 TestParseLoginResponseInvalidJSON(t *testing.T) { + t.Parallel() + + _, err := parseLoginResponse([]byte("not json")) + + require.Error(t, err) + assert.ErrorContains(t, err, "error unmarshaling login response") +} + +func TestParseLoginResponseExtInvalidJSON(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") +} + +func TestGetExtTokenSuccess(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(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 TestGetExtTokenStripsExtPrefix(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(t.Context(), "ext/token-abc", server.URL, "ext/token-abc:secret", server.Client()) + require.NoError(t, err) +} + +func TestGetExtTokenNotFound(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(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 TestGetExtTokenServerError(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(t.Context(), "token-abc", server.URL, "token-abc:secret", server.Client()) + + require.Error(t, err) + assert.ErrorContains(t, err, "500") +} + +func TestValidateTokenV3Valid(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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.True(t, ok) +} + +func TestValidateTokenV3Expired(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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateTokenV3NotFoundExtValid(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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.True(t, ok) +} + +func TestValidateTokenV3NotFoundExtExpired(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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateTokenExtPrefixSkipsV3(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(t.Context(), "ext/token-abc", v3, ext) + + require.NoError(t, err) + assert.True(t, ok) +} + +func TestValidateTokenV3NotFoundExtUnauthorized(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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateTokenV3NotFoundExtForbidden(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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateTokenV3NotFoundExtOtherError(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(t.Context(), "token-abc", v3, ext) + + require.Error(t, err) +} + +func TestGetTokenUserIDV3(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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.Equal(t, "user-v3", uid) +} + +func TestGetTokenUserIDExtFallback(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(t.Context(), "token-abc", v3, ext) + + require.NoError(t, err) + assert.Equal(t, "user-ext", uid) +} + +func TestGetTokenUserIDExtPrefixSkipsV3(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(t.Context(), "ext/token-abc", v3, ext) + + require.NoError(t, err) + assert.Equal(t, "user-ext", uid) +}