From 2187864d5c5505c525f5b12d7ac17b402257bcb4 Mon Sep 17 00:00:00 2001 From: Shweta <35878561+shwetamurali@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:23:48 -0400 Subject: [PATCH 1/4] no longer uppercase the hostname --- internal/pkg/profile/loader.go | 12 +++++++-- internal/pkg/profile/loader_test.go | 38 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/pkg/profile/loader.go b/internal/pkg/profile/loader.go index 2d4c177..a0a2782 100644 --- a/internal/pkg/profile/loader.go +++ b/internal/pkg/profile/loader.go @@ -353,10 +353,18 @@ func terraformTokenEnvVar(hostname string) string { return "" } + // Match Terraform CLI's TF_TOKEN_ naming scheme so that variables like + // TF_TOKEN_app_terraform_io are honored. The (already normalized, lowercase, + // punycode) hostname is encoded by replacing hyphens with double underscores + // and periods with single underscores. Any other character that isn't a + // letter or digit is also replaced with a single underscore. + // See https://developer.hashicorp.com/terraform/cli/config/config-file#environment-variable-credentials + hostname = strings.ReplaceAll(hostname, "-", "__") + var b strings.Builder b.WriteString("TF_TOKEN_") - for _, r := range strings.ToUpper(hostname) { - if unicode.IsLetter(r) || unicode.IsDigit(r) { + for _, r := range hostname { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' { b.WriteRune(r) continue } diff --git a/internal/pkg/profile/loader_test.go b/internal/pkg/profile/loader_test.go index f901572..326d2d5 100644 --- a/internal/pkg/profile/loader_test.go +++ b/internal/pkg/profile/loader_test.go @@ -227,6 +227,44 @@ func TestLoader_GetDeviceID(t *testing.T) { require.Equal(t, id, id2) } +func TestTerraformTokenEnvVar(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + hostname string + expected string + }{ + { + name: "hcp terraform hostname uses lowercase, matching terraform", + hostname: "app.terraform.io", + expected: "TF_TOKEN_app_terraform_io", + }, + { + name: "mixed-case hostname is normalized to lowercase", + hostname: "App.Terraform.IO", + expected: "TF_TOKEN_app_terraform_io", + }, + { + name: "hyphens are encoded as double underscores", + hostname: "my-tfe.example.com", + expected: "TF_TOKEN_my__tfe_example_com", + }, + { + name: "invalid hostname returns an empty string", + hostname: "invalid/hostname", + expected: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, c.expected, terraformTokenEnvVar(c.hostname)) + }) + } +} + //nolint:paralleltest func TestLoader_LoadProfileEnv(t *testing.T) { // These tests aren't parallel because they manipulate the environment From b0714e4fa65908b241e8ed97ed15ba0078fb158c Mon Sep 17 00:00:00 2001 From: Shweta <35878561+shwetamurali@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:32:46 -0400 Subject: [PATCH 2/4] Create BUG FIXES-20260709-163223.yaml --- .changes/unreleased/BUG FIXES-20260709-163223.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changes/unreleased/BUG FIXES-20260709-163223.yaml diff --git a/.changes/unreleased/BUG FIXES-20260709-163223.yaml b/.changes/unreleased/BUG FIXES-20260709-163223.yaml new file mode 100644 index 0000000..bdccccf --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260709-163223.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: Honor the standard Terraform `TF_TOKEN_` environment variables (such as `TF_TOKEN_app_terraform_io`) during authentication. The hostname is no longer uppercased when building the variable name, so these tokens are now detected as documented. +time: 2026-07-09T16:32:23.267381-04:00 From 01ac8b0957e09a5f999c13c65f45506b63f7195e Mon Sep 17 00:00:00 2001 From: Shweta <35878561+shwetamurali@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:01:00 -0400 Subject: [PATCH 3/4] explicitly lowercases the hostname before encoding --- internal/pkg/profile/loader.go | 6 ++++-- internal/pkg/profile/loader_test.go | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/pkg/profile/loader.go b/internal/pkg/profile/loader.go index a0a2782..dddc95d 100644 --- a/internal/pkg/profile/loader.go +++ b/internal/pkg/profile/loader.go @@ -354,11 +354,13 @@ func terraformTokenEnvVar(hostname string) string { } // Match Terraform CLI's TF_TOKEN_ naming scheme so that variables like - // TF_TOKEN_app_terraform_io are honored. The (already normalized, lowercase, - // punycode) hostname is encoded by replacing hyphens with double underscores + // TF_TOKEN_app_terraform_io are honored. Terraform lowercases the hostname; + // NormalizeHostname doesn't when a port is present, so lowercase explicitly. + // The hostname is then encoded by replacing hyphens with double underscores // and periods with single underscores. Any other character that isn't a // letter or digit is also replaced with a single underscore. // See https://developer.hashicorp.com/terraform/cli/config/config-file#environment-variable-credentials + hostname = strings.ToLower(hostname) hostname = strings.ReplaceAll(hostname, "-", "__") var b strings.Builder diff --git a/internal/pkg/profile/loader_test.go b/internal/pkg/profile/loader_test.go index 326d2d5..1f88493 100644 --- a/internal/pkg/profile/loader_test.go +++ b/internal/pkg/profile/loader_test.go @@ -245,6 +245,11 @@ func TestTerraformTokenEnvVar(t *testing.T) { hostname: "App.Terraform.IO", expected: "TF_TOKEN_app_terraform_io", }, + { + name: "mixed-case hostname with port is normalized to lowercase", + hostname: "App.Terraform.IO:8443", + expected: "TF_TOKEN_app_terraform_io_8443", + }, { name: "hyphens are encoded as double underscores", hostname: "my-tfe.example.com", From 4315b3d78bc60f726cc39294b3bc79b575cc3d3d Mon Sep 17 00:00:00 2001 From: Shweta <35878561+shwetamurali@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:15:03 -0400 Subject: [PATCH 4/4] match terraform --- .../unreleased/BUG FIXES-20260709-163223.yaml | 2 +- internal/pkg/profile/loader.go | 72 ++++++++++----- internal/pkg/profile/loader_test.go | 87 +++++++++++++++---- internal/pkg/profile/profile_test.go | 12 +++ 4 files changed, 132 insertions(+), 41 deletions(-) diff --git a/.changes/unreleased/BUG FIXES-20260709-163223.yaml b/.changes/unreleased/BUG FIXES-20260709-163223.yaml index bdccccf..1aa9709 100644 --- a/.changes/unreleased/BUG FIXES-20260709-163223.yaml +++ b/.changes/unreleased/BUG FIXES-20260709-163223.yaml @@ -1,3 +1,3 @@ kind: BUG FIXES -body: Honor the standard Terraform `TF_TOKEN_` environment variables (such as `TF_TOKEN_app_terraform_io`) during authentication. The hostname is no longer uppercased when building the variable name, so these tokens are now detected as documented. +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 dddc95d..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,32 +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 + } - // Match Terraform CLI's TF_TOKEN_ naming scheme so that variables like - // TF_TOKEN_app_terraform_io are honored. Terraform lowercases the hostname; - // NormalizeHostname doesn't when a port is present, so lowercase explicitly. - // The hostname is then encoded by replacing hyphens with double underscores - // and periods with single underscores. Any other character that isn't a - // letter or digit is also replaced with a single underscore. - // See https://developer.hashicorp.com/terraform/cli/config/config-file#environment-variable-credentials - hostname = strings.ToLower(hostname) - hostname = strings.ReplaceAll(hostname, "-", "__") - - var b strings.Builder - b.WriteString("TF_TOKEN_") - for _, r := range hostname { - if unicode.IsLetter(r) || unicode.IsDigit(r) || 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 1f88493..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,49 +228,103 @@ func TestLoader_GetDeviceID(t *testing.T) { require.Equal(t, id, id2) } -func TestTerraformTokenEnvVar(t *testing.T) { - t.Parallel() - +//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 hostname uses lowercase, matching terraform", + 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", - expected: "TF_TOKEN_app_terraform_io", + env: map[string]string{"TF_TOKEN_APP_TERRAFORM_IO": "tok"}, + expected: "tok", }, { - name: "mixed-case hostname is normalized to lowercase", - hostname: "App.Terraform.IO", - expected: "TF_TOKEN_app_terraform_io", + name: "hostname with port matches", + hostname: "app.terraform.io:8443", + env: map[string]string{"TF_TOKEN_app_terraform_io_8443": "tok"}, + expected: "tok", }, { - name: "mixed-case hostname with port is normalized to lowercase", - hostname: "App.Terraform.IO:8443", - expected: "TF_TOKEN_app_terraform_io_8443", + 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: "hyphens are encoded as double underscores", + name: "hyphenated hostname via double underscores", hostname: "my-tfe.example.com", - expected: "TF_TOKEN_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: "invalid hostname returns an empty string", + 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) { - t.Parallel() - require.Equal(t, c.expected, terraformTokenEnvVar(c.hostname)) + 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",