Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion pkg/cli/cmd/config/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ Usage Modes:

Configuration Options:
- Host: Tekton Results API server URL
- Token: Bearer token (defaults to current kubeconfig token)
- Token: Bearer token. Leave blank to resolve automatically from your kubeconfig
(recommended for exec/OIDC credentials, so it refreshes each request), or enter
a static token explicitly to persist it.
- API Path: API endpoint path
- TLS Settings: Certificate verification options

Expand Down
79 changes: 60 additions & 19 deletions pkg/cli/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,9 @@ type config struct {
// - Config: A new Config instance if successful.
// - error: An error if any step in the configuration process fails.
func NewConfig(p common.Params) (Config, error) {
kubeconfigPath := clientcmd.RecommendedHomeFile
if p.KubeConfigPath() != "" {
kubeconfigPath = p.KubeConfigPath()
}
// Load kubeConfig
cc := getRawKubeConfigLoader(kubeconfigPath)
// Load kubeConfig honoring the standard client-go precedence
// (--kubeconfig flag -> $KUBECONFIG -> ~/.kube/config).
cc := getRawKubeConfigLoader(p.KubeConfigPath())
ca := cc.ConfigAccess()
ac, err := cc.RawConfig()
if err != nil {
Expand Down Expand Up @@ -258,7 +255,12 @@ func (c *config) Set(prompt bool, p common.Params) error {
}
c.Extension.Host = strings.TrimSpace(c.Extension.Host)

token := c.Token()
// Pre-fill the Token prompt only for contexts that authenticate with a
// static (non-expiring) token. Dynamic credentials - exec plugins,
// auth-providers, token files - mint short-lived tokens that must be
// re-resolved on every request, so they are never persisted as a
// default. A token is still explicitly saved if the user types one.
token := c.tokenPromptDefault()
if err, ok := token.(error); ok {
return fmt.Errorf("failed to get token: %w", err)
}
Expand Down Expand Up @@ -380,30 +382,69 @@ func (c *config) Host() string {
return url // Return the detected URL as default
}

// Token returns the bearer token from the REST configuration.
// It returns an error if the REST configuration is not properly initialized.
// Token returns the bearer token for the current kubeconfig context.
//
// If the context carries a static token it is returned directly. If the context
// authenticates via an exec credential plugin (e.g. `oc get-token`) or a legacy
// auth-provider, the plugin/provider is invoked to mint the token so it can be
// forwarded to the Results API, matching oc/kubectl/tkn behavior. It returns an
// error only if the REST configuration is not initialized or token resolution
// fails.
//
// Returns:
// - any: The bearer token string if successful, or an error if the configuration is invalid.
// - any: The bearer token string if successful, an empty string if the
// context authenticates by non-token means, or an error on failure.
func (c *config) Token() any {
if c.RESTConfig == nil {
return fmt.Errorf("REST configuration is not initialized")
}
return c.RESTConfig.BearerToken
token, err := resolveBearerToken(c.RESTConfig)
if err != nil {
return err
}
return token
}

// getRawKubeConfigLoader creates and returns a clientcmd.ClientConfig based on the provided kubeconfig path.
// This function is equivalent to ToRawKubeConfigLoader() and is used to load the kubeconfig file.
// tokenPromptDefault returns the value used to pre-fill the "Token" prompt during
// interactive `config set`.
//
// It returns an empty string for contexts that authenticate with dynamic
// (short-lived) credentials - exec plugins, auth-providers, or token files.
// Pre-filling such a prompt with a freshly minted token would make it easy to
// inadvertently persist an expiring value, pinning it (via Extension.Token) so
// the CLI stops re-resolving credentials on every request. For static-token
// contexts the resolved token is returned so the prompt is pre-filled.
//
// The return type mirrors Token()'s: a string on success, or an error.
func (c *config) tokenPromptDefault() any {
if c.RESTConfig == nil || usesDynamicToken(c.RESTConfig) {
return ""
}
return c.Token()
}

// getRawKubeConfigLoader creates and returns a clientcmd.ClientConfig using the
// standard client-go loading rules. This function is equivalent to
// ToRawKubeConfigLoader() and honors the usual kubeconfig precedence:
// the --kubeconfig flag (explicitPath), then the $KUBECONFIG environment
// variable (which may list multiple, colon-separated files that are merged),
// and finally ~/.kube/config.
//
// Parameters:
// - kubeconfigPath: A string representing the path to the kubeconfig file.
// - explicitPath: An optional explicit kubeconfig path (e.g. from --kubeconfig).
// When empty, $KUBECONFIG / the default home file are used.
//
// Returns:
// - clientcmd.ClientConfig: A non-interactive deferred loading client configuration
// that uses the specified kubeconfig path and default overrides.
func getRawKubeConfigLoader(kubeconfigPath string) clientcmd.ClientConfig {
// Set explicit path for kubeconfig
loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}
// - clientcmd.ClientConfig: A non-interactive deferred loading client configuration.
func getRawKubeConfigLoader(explicitPath string) clientcmd.ClientConfig {
// Use the default loading rules so that $KUBECONFIG is honored, matching
// the behavior of oc/kubectl/tkn and the rest of this package (see
// common.Params). An explicit --kubeconfig path, when provided, takes
// precedence over $KUBECONFIG.
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
if explicitPath != "" {
loadingRules.ExplicitPath = explicitPath
}
configOverrides := &clientcmd.ConfigOverrides{}

// Return the clientcmd.ClientConfig (equivalent to ToRawKubeConfigLoader)
Expand Down
254 changes: 254 additions & 0 deletions pkg/cli/config/config_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package config

import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/tektoncd/results/pkg/cli/common"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)

// writeKubeconfig writes the given api.Config to a temp file and returns its path.
func writeKubeconfig(t *testing.T, cfg *clientcmdapi.Config) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "kubeconfig.yaml")
if err := clientcmd.WriteToFile(*cfg, path); err != nil {
t.Fatalf("failed to write kubeconfig: %v", err)
}
return path
}

// restConfigFromKubeconfig loads a *rest.Config from a kubeconfig file using the
// same non-interactive deferred loader NewConfig uses.
func restConfigFromKubeconfig(t *testing.T, path string) *rest.Config {
t.Helper()
loader := getRawKubeConfigLoader(path)
rc, err := loader.ClientConfig()
if err != nil {
t.Fatalf("failed to build rest.Config from %s: %v", path, err)
}
return rc
}

// staticTokenKubeconfig returns an api.Config whose only user authenticates with
// a static bearer token.
func staticTokenKubeconfig(token string) *clientcmdapi.Config {
cfg := clientcmdapi.NewConfig()
cfg.Clusters["test-cluster"] = &clientcmdapi.Cluster{
Server: "https://test-host:6443",
}
cfg.AuthInfos["test-user"] = &clientcmdapi.AuthInfo{
Token: token,
}
cfg.Contexts["test-context"] = &clientcmdapi.Context{
Cluster: "test-cluster",
AuthInfo: "test-user",
}
cfg.CurrentContext = "test-context"
return cfg
}

// execTokenKubeconfig returns an api.Config whose current-context user
// authenticates via an exec credential plugin. The plugin is a small shell
// script (written to a temp file) that prints an ExecCredential with the given
// token. This mirrors real-world exec credentials such as `oc get-token`,
// `aws eks get-token`, or `gke-gcloud-auth-plugin`, without any network access.
func execTokenKubeconfig(t *testing.T, token string) *clientcmdapi.Config {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("exec credential plugin test uses a POSIX shell script")
}

dir := t.TempDir()
plugin := filepath.Join(dir, "fake-exec-plugin.sh")
script := fmt.Sprintf(`#!/bin/sh
cat <<'JSON'
{
"apiVersion": "client.authentication.k8s.io/v1",
"kind": "ExecCredential",
"status": {
"token": %q
}
}
JSON
`, token)
if err := os.WriteFile(plugin, []byte(script), 0o700); err != nil {
t.Fatalf("failed to write exec plugin: %v", err)
}

cfg := clientcmdapi.NewConfig()
cfg.Clusters["test-cluster"] = &clientcmdapi.Cluster{
Server: "https://test-host:6443",
}
cfg.AuthInfos["test-user"] = &clientcmdapi.AuthInfo{
Exec: &clientcmdapi.ExecConfig{
APIVersion: "client.authentication.k8s.io/v1",
Command: plugin,
InteractiveMode: clientcmdapi.NeverExecInteractiveMode,
},
}
cfg.Contexts["test-context"] = &clientcmdapi.Context{
Cluster: "test-cluster",
AuthInfo: "test-user",
}
cfg.CurrentContext = "test-context"
return cfg
}

// TestGetRawKubeConfigLoaderHonorsKUBECONFIG verifies that the kubeconfig loader
// honors the $KUBECONFIG environment variable (previously it hardcoded
// ~/.kube/config and only respected an explicit --kubeconfig path).
func TestGetRawKubeConfigLoaderHonorsKUBECONFIG(t *testing.T) {
// A context that only exists in a file referenced via $KUBECONFIG.
kubeconfigPath := writeKubeconfig(t, staticTokenKubeconfig("kubeconfig-env-token"))

// Ensure the default home file is NOT used by pointing HOME at an empty dir.
t.Setenv("HOME", t.TempDir())
t.Setenv("KUBECONFIG", kubeconfigPath)

// explicitPath empty => should fall back to $KUBECONFIG.
loader := getRawKubeConfigLoader("")
rawConfig, err := loader.RawConfig()
if err != nil {
t.Fatalf("RawConfig() failed: %v", err)
}
if rawConfig.CurrentContext != "test-context" {
t.Fatalf("expected current-context from $KUBECONFIG file, got %q", rawConfig.CurrentContext)
}
if _, ok := rawConfig.Contexts["test-context"]; !ok {
t.Fatalf("expected context from $KUBECONFIG to be loaded; contexts: %v", rawConfig.Contexts)
}
}

// TestGetRawKubeConfigLoaderExplicitPathTakesPrecedence verifies that an
// explicit --kubeconfig path wins over $KUBECONFIG.
func TestGetRawKubeConfigLoaderExplicitPathTakesPrecedence(t *testing.T) {
envPath := writeKubeconfig(t, func() *clientcmdapi.Config {
c := staticTokenKubeconfig("env-token")
// Rename the context so we can tell the files apart.
c.Contexts["env-context"] = c.Contexts["test-context"]
delete(c.Contexts, "test-context")
c.CurrentContext = "env-context"
return c
}())
explicitPath := writeKubeconfig(t, func() *clientcmdapi.Config {
c := staticTokenKubeconfig("explicit-token")
c.Contexts["explicit-context"] = c.Contexts["test-context"]
delete(c.Contexts, "test-context")
c.CurrentContext = "explicit-context"
return c
}())

t.Setenv("HOME", t.TempDir())
t.Setenv("KUBECONFIG", envPath)

loader := getRawKubeConfigLoader(explicitPath)
rawConfig, err := loader.RawConfig()
if err != nil {
t.Fatalf("RawConfig() failed: %v", err)
}
if rawConfig.CurrentContext != "explicit-context" {
t.Fatalf("expected explicit --kubeconfig to take precedence, got current-context %q", rawConfig.CurrentContext)
}
}

// TestNewConfigHonorsKUBECONFIG verifies end-to-end that NewConfig resolves the
// current context from $KUBECONFIG when no --kubeconfig flag is provided.
// Previously this failed with "context ” not found in kubeconfig".
func TestNewConfigHonorsKUBECONFIG(t *testing.T) {
kubeconfigPath := writeKubeconfig(t, staticTokenKubeconfig("kubeconfig-env-token"))

t.Setenv("HOME", t.TempDir())
t.Setenv("KUBECONFIG", kubeconfigPath)

p := &common.ResultsParams{}
// Note: KubeConfigPath is intentionally NOT set, so resolution must come
// from $KUBECONFIG.
cfg, err := NewConfig(p)
if err != nil {
t.Fatalf("NewConfig() failed (should honor $KUBECONFIG): %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
token := cfg.(*config).Token()
if tokenStr, ok := token.(string); !ok || tokenStr != "kubeconfig-env-token" {
t.Fatalf("expected token resolved from $KUBECONFIG file, got %v (%T)", token, token)
}
}

// TestTokenPromptDefault verifies the interactive `config set` source-awareness:
// the Token prompt is pre-filled for contexts with a static (non-expiring)
// token, but left blank for contexts whose tokens are minted dynamically by an
// exec credential plugin (e.g. `oc get-token`) so a short-lived token is never
// persisted as a default.
func TestTokenPromptDefault(t *testing.T) {
t.Run("static token is pre-filled", func(t *testing.T) {
kubeconfigPath := writeKubeconfig(t, staticTokenKubeconfig("static-prompt-token"))

p := &common.ResultsParams{}
p.SetKubeConfigPath(kubeconfigPath)
p.SetKubeContext("test-context")
cfg, err := NewConfig(p)
if err != nil {
t.Fatalf("NewConfig() failed: %v", err)
}

def := cfg.(*config).tokenPromptDefault()
got, ok := def.(string)
if !ok {
t.Fatalf("expected tokenPromptDefault() to return a string, got %T (%v)", def, def)
}
if got != "static-prompt-token" {
t.Fatalf("expected static token to be pre-filled, got %q", got)
}
})

t.Run("exec credential is not pre-filled", func(t *testing.T) {
kubeconfigPath := writeKubeconfig(t, execTokenKubeconfig(t, "exec-should-not-persist"))

p := &common.ResultsParams{}
p.SetKubeConfigPath(kubeconfigPath)
p.SetKubeContext("test-context")
cfg, err := NewConfig(p)
if err != nil {
t.Fatalf("NewConfig() failed: %v", err)
}

def := cfg.(*config).tokenPromptDefault()
got, ok := def.(string)
if !ok {
t.Fatalf("expected tokenPromptDefault() to return a string, got %T (%v)", def, def)
}
if got != "" {
t.Fatalf("expected exec credential NOT to pre-fill the token prompt, got %q", got)
}
})
}

func TestTokenExecCredential(t *testing.T) {
kubeconfigPath := writeKubeconfig(t, execTokenKubeconfig(t, "exec-minted-token-via-token"))

p := &common.ResultsParams{}
p.SetKubeConfigPath(kubeconfigPath)
p.SetKubeContext("test-context")
cfg, err := NewConfig(p)
if err != nil {
t.Fatalf("NewConfig() failed: %v", err)
}

token := cfg.(*config).Token()
tokenStr, ok := token.(string)
if !ok {
t.Fatalf("expected Token() to return a string, got %T (%v)", token, token)
}
if tokenStr != "exec-minted-token-via-token" {
t.Fatalf("expected Token() to resolve exec token, got %q", tokenStr)
}
}
Loading
Loading