diff --git a/.changes/unreleased/BUG FIXES-20260709-163223.yaml b/.changes/unreleased/BUG FIXES-20260709-163223.yaml new file mode 100644 index 0000000..1aa9709 --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260709-163223.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: Detect Terraform's `TF_TOKEN_` environment variables (such as `TF_TOKEN_app_terraform_io`) during authentication, matching Terraform CLI's resolution. This includes punycode hostnames and the interchangeable dash encodings (literal `-` or double underscore). Previously these tokens were not detected. +time: 2026-07-09T16:32:23.267381-04:00 diff --git a/internal/pkg/profile/loader.go b/internal/pkg/profile/loader.go index 2d4c177..a802ba8 100644 --- a/internal/pkg/profile/loader.go +++ b/internal/pkg/profile/loader.go @@ -12,7 +12,6 @@ import ( "os" "path/filepath" "strings" - "unicode" "github.com/google/uuid" "github.com/hashicorp/hcl/v2/hclsimple" @@ -256,11 +255,11 @@ func (l *Loader) LoadProfile(ctx context.Context, name string) (*Profile, error) } } - // 3. Check for a token in the terraform environment variable that matches the hostname of the - // profile (support for TF_TOKEN_{normalizedHostname} + // 3. Check for a token in a terraform environment variable that matches the hostname of the + // profile (support for TF_TOKEN_{host}). if c.GetToken() == "" { - if envToken := os.Getenv(terraformTokenEnvVar(c.GetHostname())); envToken != "" { - logger.Debug("Setting token from terraform environment", "var", terraformTokenEnvVar(c.GetHostname())) + if envToken := tokenFromTerraformEnv(c.GetHostname()); envToken != "" { + logger.Debug("Setting token from terraform environment", "hostname", c.GetHostname()) c.tokenFromEnv = envToken } } @@ -347,22 +346,57 @@ func profileTokenEnvVar(profileName string) string { return fmt.Sprintf(envVarTokenProfileFormat, profileName) } -func terraformTokenEnvVar(hostname string) string { - hostname, err := NormalizeHostname(hostname) +// tokenFromTerraformEnv returns the token from a Terraform-style TF_TOKEN_ +// environment variable that matches the given hostname, mirroring Terraform CLI's +// resolution. Terraform scans every environment variable with the TF_TOKEN_ prefix +// and decodes the remainder of the name back into a hostname: double underscores +// become hyphens and any remaining single underscore becomes a period. This means a +// single hostname may be expressed by several variable names (for example the +// punycode host xn--caf-dma.fr can be written as TF_TOKEN_xn--caf-dma_fr, +// TF_TOKEN_xn--caf-dma.fr, or TF_TOKEN_xn____caf__dma_fr). If multiple variables +// resolve to the same hostname, the one defined last wins. +// See https://developer.hashicorp.com/terraform/cli/config/config-file#environment-variable-credentials +func tokenFromTerraformEnv(hostname string) string { + target, err := NormalizeHostname(hostname) if err != nil { return "" } + // Terraform's encoding can only produce periods (via single underscores), so a + // hostname's port separator (":") is indistinguishable from a period once + // encoded. Normalize both sides to periods so ported hosts like + // app.terraform.io:8443 still match TF_TOKEN_app_terraform_io_8443. + target = normalizeTerraformTokenHost(target) + + const prefix = "TF_TOKEN_" + var token string + for _, env := range os.Environ() { + name, value, ok := strings.Cut(env, "=") + if !ok || !strings.HasPrefix(name, prefix) { + continue + } - var b strings.Builder - b.WriteString("TF_TOKEN_") - for _, r := range strings.ToUpper(hostname) { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) + // Decode Terraform's encoding of the hostname portion: double underscores + // are hyphens, and any remaining single underscore is a period. + rawHost := name[len(prefix):] + rawHost = strings.ReplaceAll(rawHost, "__", "-") + rawHost = strings.ReplaceAll(rawHost, "_", ".") + + candidate, err := NormalizeHostname(rawHost) + if err != nil { continue } - b.WriteRune('_') + if normalizeTerraformTokenHost(candidate) == target { + // Keep going so the last-defined matching variable wins. + token = value + } } - return b.String() + return token +} + +// normalizeTerraformTokenHost lowercases a hostname and treats the port separator +// as a period so that encoded and decoded forms compare equal. +func normalizeTerraformTokenHost(hostname string) string { + return strings.ReplaceAll(strings.ToLower(hostname), ":", ".") } type credentialsFile struct { diff --git a/internal/pkg/profile/loader_test.go b/internal/pkg/profile/loader_test.go index f901572..6142a44 100644 --- a/internal/pkg/profile/loader_test.go +++ b/internal/pkg/profile/loader_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "github.com/stretchr/testify/require" @@ -227,6 +228,103 @@ func TestLoader_GetDeviceID(t *testing.T) { require.Equal(t, id, id2) } +//nolint:paralleltest // manipulates the environment, can't run in parallel +func TestTokenFromTerraformEnv(t *testing.T) { + cases := []struct { + name string + hostname string + env map[string]string + expected string + }{ + { + name: "hcp terraform token via lowercase variable", + hostname: "app.terraform.io", + env: map[string]string{"TF_TOKEN_app_terraform_io": "tok"}, + expected: "tok", + }, + { + name: "uppercase variable name still matches", + hostname: "app.terraform.io", + env: map[string]string{"TF_TOKEN_APP_TERRAFORM_IO": "tok"}, + expected: "tok", + }, + { + name: "hostname with port matches", + hostname: "app.terraform.io:8443", + env: map[string]string{"TF_TOKEN_app_terraform_io_8443": "tok"}, + expected: "tok", + }, + { + name: "hyphenated hostname via literal dashes", + hostname: "my-tfe.example.com", + env: map[string]string{"TF_TOKEN_my-tfe_example_com": "tok"}, + expected: "tok", + }, + { + name: "hyphenated hostname via double underscores", + hostname: "my-tfe.example.com", + env: map[string]string{"TF_TOKEN_my__tfe_example_com": "tok"}, + expected: "tok", + }, + { + name: "punycode hostname via literal dashes and period", + hostname: "café.fr", + env: map[string]string{"TF_TOKEN_xn--caf-dma.fr": "tok"}, + expected: "tok", + }, + { + name: "punycode hostname via literal dashes", + hostname: "café.fr", + env: map[string]string{"TF_TOKEN_xn--caf-dma_fr": "tok"}, + expected: "tok", + }, + { + name: "punycode hostname via double underscores", + hostname: "café.fr", + env: map[string]string{"TF_TOKEN_xn____caf__dma_fr": "tok"}, + expected: "tok", + }, + { + name: "no matching variable returns empty", + hostname: "app.terraform.io", + env: map[string]string{"TF_TOKEN_other_example_com": "tok"}, + expected: "", + }, + { + name: "invalid hostname returns empty", + hostname: "invalid/hostname", + env: map[string]string{"TF_TOKEN_app_terraform_io": "tok"}, + expected: "", + }, + } + + for _, c := range cases { + //nolint:paralleltest // uses t.Setenv + t.Run(c.name, func(t *testing.T) { + clearTerraformTokenEnv(t) + for k, v := range c.env { + t.Setenv(k, v) + } + require.Equal(t, c.expected, tokenFromTerraformEnv(c.hostname)) + }) + } +} + +// clearTerraformTokenEnv removes any TF_TOKEN_* variables already present in the +// test runner's environment so the test controls exactly which ones are set. The +// original values are restored when the test finishes. +func clearTerraformTokenEnv(t *testing.T) { + t.Helper() + for _, env := range os.Environ() { + name, _, ok := strings.Cut(env, "=") + if !ok || !strings.HasPrefix(name, "TF_TOKEN_") { + continue + } + t.Setenv(name, "") // registers restoration of the original value + require.NoError(t, os.Unsetenv(name)) + } +} + //nolint:paralleltest func TestLoader_LoadProfileEnv(t *testing.T) { // These tests aren't parallel because they manipulate the environment diff --git a/internal/pkg/profile/profile_test.go b/internal/pkg/profile/profile_test.go index 5274158..fd40b63 100644 --- a/internal/pkg/profile/profile_test.go +++ b/internal/pkg/profile/profile_test.go @@ -157,6 +157,18 @@ func TestNormalizeHostname(t *testing.T) { Input: "täst.com", Expected: "xn--tst-qla.com", }, + { + // Documented Terraform example: https://developer.hashicorp.com/terraform/cli/config/config-file#environment-variable-credentials + Name: "unicode hostname converts to punycode (café.fr)", + Input: "café.fr", + Expected: "xn--caf-dma.fr", + }, + { + // Documented Terraform example for a non-ASCII host. + Name: "unicode hostname converts to punycode (例えば.com)", + Input: "例えば.com", + Expected: "xn--r8j3dr99h.com", + }, { Name: "ipv4 hostname with port", Input: "127.0.0.1:9000",