Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .changes/unreleased/BUG FIXES-20260709-163223.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: BUG FIXES
body: Detect Terraform's `TF_TOKEN_<hostname>` 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
62 changes: 48 additions & 14 deletions internal/pkg/profile/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"os"
"path/filepath"
"strings"
"unicode"

"github.com/google/uuid"
"github.com/hashicorp/hcl/v2/hclsimple"
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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_<host>
// 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 {
Expand Down
98 changes: 98 additions & 0 deletions internal/pkg/profile/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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",
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that when using a port we can still get mixed-case hostnames. Here's a test case to reproduce:

Suggested change
},
},
{
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: "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: "",
},
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test case from NormalizeHostname that uses unicode characters, as documented in this section of the terraform docs? and make sure it works the same way? It allows dashes to be in the env variable OR as a double underscore.

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
Expand Down
12 changes: 12 additions & 0 deletions internal/pkg/profile/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down