diff --git a/.nextchanges/cli/profile-fingerprint.md b/.nextchanges/cli/profile-fingerprint.md new file mode 100644 index 00000000000..3115c5963d0 --- /dev/null +++ b/.nextchanges/cli/profile-fingerprint.md @@ -0,0 +1 @@ +Require a new OAuth login before reusing cached credentials after the corresponding profile configuration changes. diff --git a/acceptance/bin/profile_fingerprint.py b/acceptance/bin/profile_fingerprint.py new file mode 100755 index 00000000000..9b9ff73ef46 --- /dev/null +++ b/acceptance/bin/profile_fingerprint.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +import hashlib +import sys + + +def append_uvarint(data, value): + while value >= 0x80: + data.append((value & 0x7F) | 0x80) + value >>= 7 + data.append(value) + + +values = { + "auth_type": "databricks-cli", + "host": sys.argv[1], +} +serialized = bytearray() +for key in sorted(values): + value = values[key] + append_uvarint(serialized, len(key.encode())) + serialized.extend(key.encode()) + append_uvarint(serialized, len(value.encode())) + serialized.extend(value.encode()) + +print(hashlib.sha256(serialized).hexdigest()) diff --git a/acceptance/cmd/auth/token/legacy-profile-fingerprint/out.test.toml b/acceptance/cmd/auth/token/legacy-profile-fingerprint/out.test.toml new file mode 100644 index 00000000000..98ea5040486 --- /dev/null +++ b/acceptance/cmd/auth/token/legacy-profile-fingerprint/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/token/legacy-profile-fingerprint/output.txt b/acceptance/cmd/auth/token/legacy-profile-fingerprint/output.txt new file mode 100644 index 00000000000..cc13249b015 --- /dev/null +++ b/acceptance/cmd/auth/token/legacy-profile-fingerprint/output.txt @@ -0,0 +1,3 @@ + +>>> musterr [CLI] auth token --profile test-profile +Error: cache: cached credentials for profile "test-profile" predate profile change detection; run `databricks auth login --profile "test-profile"` to sign in again diff --git a/acceptance/cmd/auth/token/legacy-profile-fingerprint/script b/acceptance/cmd/auth/token/legacy-profile-fingerprint/script new file mode 100644 index 00000000000..5e1816fa9c8 --- /dev/null +++ b/acceptance/cmd/auth/token/legacy-profile-fingerprint/script @@ -0,0 +1,10 @@ +# A token without fingerprint metadata must require a new login. +setup_test_profile +setup_test_token_cache + +# Tokens created before profile fingerprinting require one new interactive login. +jq 'del(.tokens["test-profile"].profile_fingerprint)' \ + "./home/.databricks/token-cache.json" > "./token-cache.json" +mv "./token-cache.json" "./home/.databricks/token-cache.json" + +trace musterr $CLI auth token --profile test-profile diff --git a/acceptance/cmd/auth/token/legacy-profile-fingerprint/test.toml b/acceptance/cmd/auth/token/legacy-profile-fingerprint/test.toml new file mode 100644 index 00000000000..45851c79afa --- /dev/null +++ b/acceptance/cmd/auth/token/legacy-profile-fingerprint/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home", +] diff --git a/acceptance/cmd/auth/token/profile-change/out.test.toml b/acceptance/cmd/auth/token/profile-change/out.test.toml new file mode 100644 index 00000000000..98ea5040486 --- /dev/null +++ b/acceptance/cmd/auth/token/profile-change/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/token/profile-change/output.txt b/acceptance/cmd/auth/token/profile-change/output.txt new file mode 100644 index 00000000000..5440be048e0 --- /dev/null +++ b/acceptance/cmd/auth/token/profile-change/output.txt @@ -0,0 +1,7 @@ + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile fingerprint-test --scopes jobs +Profile fingerprint-test was successfully saved + +>>> musterr [CLI] auth token --profile fingerprint-test +Error: cache: profile "fingerprint-test" has changed since the last login; run `databricks auth login --profile "fingerprint-test"` to sign in again +Token cache unchanged diff --git a/acceptance/cmd/auth/token/profile-change/script b/acceptance/cmd/auth/token/profile-change/script new file mode 100644 index 00000000000..e0530bd041e --- /dev/null +++ b/acceptance/cmd/auth/token/profile-change/script @@ -0,0 +1,18 @@ +# A cached token must be rejected without mutation after its profile changes. +sethome "./home" +export BROWSER="browser.py" +export DATABRICKS_AUTH_STORAGE=plaintext + +trace $CLI auth login --host $DATABRICKS_HOST --profile fingerprint-test --scopes jobs + +cp "./home/.databricks/token-cache.json" "./token-cache.before.json" + +# Editing any parsed profile value must block cached-token reuse. +sed -i 's/scopes = jobs/scopes = all-apis,sql/' "./home/.databrickscfg" +trace musterr $CLI auth token --profile fingerprint-test + +if cmp -s "./token-cache.before.json" "./home/.databricks/token-cache.json"; then + echo "Token cache unchanged" +else + echo "Token cache changed" +fi diff --git a/acceptance/cmd/auth/token/profile-change/test.toml b/acceptance/cmd/auth/token/profile-change/test.toml new file mode 100644 index 00000000000..7f449329e78 --- /dev/null +++ b/acceptance/cmd/auth/token/profile-change/test.toml @@ -0,0 +1,4 @@ +Ignore = [ + "home", + "token-cache.before.json", +] diff --git a/acceptance/cmd/auth/token/script.prepare b/acceptance/cmd/auth/token/script.prepare index afdf008034d..94f07e6f683 100644 --- a/acceptance/cmd/auth/token/script.prepare +++ b/acceptance/cmd/auth/token/script.prepare @@ -15,6 +15,8 @@ ENDCFG setup_test_token_cache() { mkdir -p "./home/.databricks" + # Match the cache metadata that login writes for the two-field test profile. + profile_fingerprint=$(profile_fingerprint.py "$DATABRICKS_HOST_ORIG") cat > "./home/.databricks/token-cache.json" <", // "token_type": "", // "refresh_token": "", - // "expiry": "" + // "expiry": "", + // "profile_fingerprint": "" // } // } // } @@ -50,6 +51,7 @@ const ( // the historical bare-token format, leaving room for additive sibling fields. type fileEntry struct { *oauth2.Token + ProfileFingerprint string `json:"profile_fingerprint,omitempty"` } // tokenStoreFile is the format of the token store file. @@ -106,7 +108,10 @@ func (c *fileStore) Put(key string, e Entry) error { if f.Tokens == nil { f.Tokens = map[string]*fileEntry{} } - f.Tokens[key] = &fileEntry{Token: e.Token} + f.Tokens[key] = &fileEntry{ + Token: e.Token, + ProfileFingerprint: e.ProfileFingerprint, + } return c.write(f) } @@ -122,7 +127,10 @@ func (c *fileStore) Lookup(key string) (Entry, error) { if !ok { return Entry{}, ErrNotFound } - return Entry{Token: fe.Token}, nil + return Entry{ + Token: fe.Token, + ProfileFingerprint: fe.ProfileFingerprint, + }, nil } // Delete implements the Store interface. Removing a missing key is a no-op. diff --git a/libs/auth/storage/filestore_test.go b/libs/auth/storage/filestore_test.go index d905e304839..a22d2527dff 100644 --- a/libs/auth/storage/filestore_test.go +++ b/libs/auth/storage/filestore_test.go @@ -18,9 +18,12 @@ func setup(t *testing.T) string { func TestStoreAndLookup(t *testing.T) { c, err := NewFileStore(t.Context(), WithFileLocation(setup(t))) require.NoError(t, err) - err = c.Put("x", Entry{Token: &oauth2.Token{ - AccessToken: "abc", - }}) + err = c.Put("x", Entry{ + Token: &oauth2.Token{ + AccessToken: "abc", + }, + ProfileFingerprint: "fingerprint-x", + }) require.NoError(t, err) err = c.Put("y", Entry{Token: &oauth2.Token{ @@ -31,6 +34,7 @@ func TestStoreAndLookup(t *testing.T) { got, err := c.Lookup("x") require.NoError(t, err) assert.Equal(t, "abc", got.Token.AccessToken) + assert.Equal(t, "fingerprint-x", got.ProfileFingerprint) _, err = c.Lookup("z") assert.Equal(t, ErrNotFound, err) diff --git a/libs/auth/storage/keyring.go b/libs/auth/storage/keyring.go index abfedf46d3d..546803d28be 100644 --- a/libs/auth/storage/keyring.go +++ b/libs/auth/storage/keyring.go @@ -47,7 +47,8 @@ type keyringBackend interface { // checksum, store time, ...) without breaking older CLI versions that read // the same entry. type keyringEntry struct { - Token *oauth2.Token `json:"token"` + Token *oauth2.Token `json:"token"` + ProfileFingerprint string `json:"profile_fingerprint,omitempty"` } // zalandoBackend delegates to the process-wide zalando/go-keyring provider. diff --git a/libs/auth/storage/keyring_test.go b/libs/auth/storage/keyring_test.go index 9a2074931aa..2d547b9cf78 100644 --- a/libs/auth/storage/keyring_test.go +++ b/libs/auth/storage/keyring_test.go @@ -82,7 +82,10 @@ func TestKeyringStore_Store_WritesJSON(t *testing.T) { tok := &oauth2.Token{AccessToken: "abc", TokenType: "Bearer"} - require.NoError(t, c.Put("my-profile", Entry{Token: tok})) + require.NoError(t, c.Put("my-profile", Entry{ + Token: tok, + ProfileFingerprint: "profile-fingerprint", + })) stored, ok := backend.items[itemKey("databricks-cli", "my-profile")] require.True(t, ok, "token should be stored under service=databricks-cli, account=my-profile") @@ -92,6 +95,7 @@ func TestKeyringStore_Store_WritesJSON(t *testing.T) { require.NotNil(t, got.Token) assert.Equal(t, "abc", got.Token.AccessToken) assert.Equal(t, "Bearer", got.Token.TokenType) + assert.Equal(t, "profile-fingerprint", got.ProfileFingerprint) } func TestKeyringStore_Store_PropagatesBackendError(t *testing.T) { @@ -110,12 +114,16 @@ func TestKeyringStore_Lookup_ReturnsStoredToken(t *testing.T) { c := newTestStore(backend) want := &oauth2.Token{AccessToken: "abc", TokenType: "Bearer"} - require.NoError(t, c.Put("my-profile", Entry{Token: want})) + require.NoError(t, c.Put("my-profile", Entry{ + Token: want, + ProfileFingerprint: "profile-fingerprint", + })) got, err := c.Lookup("my-profile") require.NoError(t, err) assert.Equal(t, "abc", got.Token.AccessToken) assert.Equal(t, "Bearer", got.Token.TokenType) + assert.Equal(t, "profile-fingerprint", got.ProfileFingerprint) } func TestKeyringStore_Lookup_MissingReturnsCacheErrNotFound(t *testing.T) { diff --git a/libs/auth/storage/profile_fingerprint.go b/libs/auth/storage/profile_fingerprint.go new file mode 100644 index 00000000000..c01318d824e --- /dev/null +++ b/libs/auth/storage/profile_fingerprint.go @@ -0,0 +1,89 @@ +package storage + +import ( + "fmt" +) + +// ProfileFingerprintError tells the user why an otherwise valid cached token +// cannot be reused after the corresponding profile changed. +type ProfileFingerprintError struct { + Profile string + Missing bool +} + +func (e *ProfileFingerprintError) Error() string { + if e.Missing { + return fmt.Sprintf("cached credentials for profile %q predate profile change detection; run `databricks auth login --profile %q` to sign in again", e.Profile, e.Profile) + } + return fmt.Sprintf("profile %q has changed since the last login; run `databricks auth login --profile %q` to sign in again", e.Profile, e.Profile) +} + +func (e *ProfileFingerprintError) Unwrap() error { + return ErrProfileChanged +} + +// ProfileFingerprintStore stamps token writes and rejects reads whose metadata +// does not match the current profile. +type ProfileFingerprintStore struct { + inner Store + profile string + fingerprint string +} + +// NewProfileFingerprintStore binds token reads and refresh writes to fingerprint. +func NewProfileFingerprintStore(inner Store, profile, fingerprint string) *ProfileFingerprintStore { + return &ProfileFingerprintStore{ + inner: inner, + profile: profile, + fingerprint: fingerprint, + } +} + +func (s *ProfileFingerprintStore) Put(key string, entry Entry) error { + // The SDK replaces the entire token entry after a refresh, so stamp the + // binding again instead of losing it with the old access token. + entry.ProfileFingerprint = s.fingerprint + + return s.inner.Put(key, entry) +} + +func (s *ProfileFingerprintStore) Lookup(key string) (Entry, error) { + entry, err := s.inner.Lookup(key) + if err != nil { + return Entry{}, err + } + + if entry.ProfileFingerprint == "" { + return Entry{}, &ProfileFingerprintError{Profile: s.profile, Missing: true} + } + + if entry.ProfileFingerprint != s.fingerprint { + return Entry{}, &ProfileFingerprintError{Profile: s.profile} + } + + return entry, nil +} + +func (s *ProfileFingerprintStore) Delete(key string) error { + return s.inner.Delete(key) +} + +// SetProfileFingerprint binds an existing profile-keyed token to the profile +// left on disk after login. Legacy host-key copies are intentionally excluded +// because one host can be shared by multiple profiles. +func SetProfileFingerprint(store Store, profile, fingerprint string) error { + entry, err := store.Lookup(profile) + if err != nil { + return fmt.Errorf("load token %q: %w", profile, err) + } + + entry.ProfileFingerprint = fingerprint + + if err := store.Put(profile, entry); err != nil { + return fmt.Errorf("update token %q: %w", profile, err) + } + + return nil +} + +var _ Store = (*ProfileFingerprintStore)(nil) diff --git a/libs/auth/storage/profile_fingerprint_test.go b/libs/auth/storage/profile_fingerprint_test.go new file mode 100644 index 00000000000..0a6a20073c7 --- /dev/null +++ b/libs/auth/storage/profile_fingerprint_test.go @@ -0,0 +1,95 @@ +package storage + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// TestProfileFingerprintStoreLookup verifies that matching entries can be read while +// changed and legacy entries are rejected with the appropriate mismatch details. +func TestProfileFingerprintStoreLookup(t *testing.T) { + currentFingerprint := "current" + + tests := []struct { + name string + storedFingerprint string + wantMissing bool + wantErr bool + }{ + { + name: "matching fingerprint", + storedFingerprint: currentFingerprint, + }, + { + name: "changed fingerprint", + storedFingerprint: "old", + wantErr: true, + }, + { + name: "missing legacy fingerprint", + wantMissing: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inner := newMemStore() + store := NewProfileFingerprintStore(inner, "TEST", currentFingerprint) + require.NoError(t, inner.Put("TEST", Entry{ + Token: &oauth2.Token{AccessToken: "token"}, + ProfileFingerprint: tt.storedFingerprint, + })) + + got, err := store.Lookup("TEST") + if !tt.wantErr { + require.NoError(t, err) + assert.Equal(t, "token", got.Token.AccessToken) + return + } + + assert.ErrorIs(t, err, ErrProfileChanged) + changedErr, ok := errors.AsType[*ProfileFingerprintError](err) + require.True(t, ok) + assert.Equal(t, tt.wantMissing, changedErr.Missing) + }) + } +} + +// TestProfileFingerprintStoreStampsWrites verifies that replacement token writes retain +// the fingerprint binding, as required when OAuth refresh replaces a cache entry. +func TestProfileFingerprintStoreStampsWrites(t *testing.T) { + inner := newMemStore() + store := NewProfileFingerprintStore(inner, "TEST", "current") + + require.NoError(t, store.Put("TEST", Entry{Token: &oauth2.Token{AccessToken: "token"}})) + entry, err := inner.Lookup("TEST") + require.NoError(t, err) + assert.Equal(t, "current", entry.ProfileFingerprint) +} + +// TestSetProfileFingerprintOnlyUpdatesProfileKey verifies that login binds the +// profile-keyed token without binding the shared legacy host-keyed copy. +func TestSetProfileFingerprintOnlyUpdatesProfileKey(t *testing.T) { + inner := newMemStore() + + require.NoError(t, inner.Put("TEST", Entry{Token: &oauth2.Token{AccessToken: "token"}})) + require.NoError(t, inner.Put("https://workspace.example.com", Entry{Token: &oauth2.Token{AccessToken: "token"}})) + require.NoError(t, SetProfileFingerprint(inner, "TEST", "current")) + + entry, err := inner.Lookup("TEST") + require.NoError(t, err) + + assert.Equal(t, "current", entry.ProfileFingerprint) + + // A host can be shared by multiple profiles, so its compatibility copy is + // not bound to any one profile. + hostEntry, err := inner.Lookup("https://workspace.example.com") + require.NoError(t, err) + + assert.Empty(t, hostEntry.ProfileFingerprint) +} diff --git a/libs/auth/storage/storage.go b/libs/auth/storage/storage.go index 785c716d224..753edb03844 100644 --- a/libs/auth/storage/storage.go +++ b/libs/auth/storage/storage.go @@ -20,6 +20,10 @@ import ( // ToU2MTokenCache translates between the two. var ErrNotFound = errors.New("token not found") +// ErrProfileChanged identifies cached credentials that no longer correspond +// to the profile that selected them. +var ErrProfileChanged = errors.New("profile configuration changed") + // Entry is the value held in the CLI token store. It wraps the credential so // the schema can grow additive metadata (e.g. a config fingerprint, scopes) // without changing the Store interface. Backends persist it verbatim and never @@ -27,6 +31,10 @@ var ErrNotFound = errors.New("token not found") type Entry struct { // Token is the cached OAuth token. Always set for stored entries. Token *oauth2.Token + + // ProfileFingerprint binds the OAuth grant to the complete profile that + // was present when the token was minted. + ProfileFingerprint string } // Store is the CLI's token-storage abstraction: a key/value store with no diff --git a/libs/databrickscfg/profile/file.go b/libs/databrickscfg/profile/file.go index b7f6074c811..0dbfd0f195f 100644 --- a/libs/databrickscfg/profile/file.go +++ b/libs/databrickscfg/profile/file.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/databricks/cli/libs/databrickscfg/profilehash" "github.com/databricks/cli/libs/env" "github.com/databricks/databricks-sdk-go/config" "github.com/spf13/cobra" @@ -88,6 +89,7 @@ func (f FileProfilerImpl) LoadProfiles(ctx context.Context, fn ProfileMatchFunct HasClientCredentials: all["client_id"] != "" && all["client_secret"] != "", Scopes: all["scopes"], AuthType: all["auth_type"], + fingerprint: profilehash.Compute(all), } if fn(profile) { profiles = append(profiles, profile) diff --git a/libs/databrickscfg/profile/profile.go b/libs/databrickscfg/profile/profile.go index efd358cd4e5..20e01cd59e9 100644 --- a/libs/databrickscfg/profile/profile.go +++ b/libs/databrickscfg/profile/profile.go @@ -19,6 +19,12 @@ type Profile struct { HasClientCredentials bool Scopes string AuthType string + fingerprint string +} + +// Fingerprint returns the hash captured when the profile was loaded. +func (p Profile) Fingerprint() string { + return p.fingerprint } func (p Profile) Cloud() string { diff --git a/libs/databrickscfg/profile_fingerprint_test.go b/libs/databrickscfg/profile_fingerprint_test.go new file mode 100644 index 00000000000..926e43126a9 --- /dev/null +++ b/libs/databrickscfg/profile_fingerprint_test.go @@ -0,0 +1,36 @@ +package databrickscfg_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/databrickscfg/profilehash" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFileProfilerCapturesFingerprintOfAllProfileFields verifies that loaded profiles +// include known and unknown configuration fields in their fingerprint. +func TestFileProfilerCapturesFingerprintOfAllProfileFields(t *testing.T) { + configPath := filepath.Join(t.TempDir(), ".databrickscfg") + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + require.NoError(t, os.WriteFile(configPath, []byte(`[TEST] +host = https://workspace.example.com +auth_type = databricks-cli +custom_key = custom-value +`), 0o600)) + + want := profilehash.Compute(map[string]string{ + "host": "https://workspace.example.com", + "auth_type": "databricks-cli", + "custom_key": "custom-value", + }) + + profiles, err := profile.DefaultProfiler.LoadProfiles(t.Context(), profile.WithName("TEST")) + require.NoError(t, err) + require.Len(t, profiles, 1) + + assert.Equal(t, want, profiles[0].Fingerprint()) +} diff --git a/libs/databrickscfg/profilehash/profilehash.go b/libs/databrickscfg/profilehash/profilehash.go new file mode 100644 index 00000000000..a883996a58f --- /dev/null +++ b/libs/databrickscfg/profilehash/profilehash.go @@ -0,0 +1,45 @@ +package profilehash + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "maps" + "slices" + + "github.com/databricks/databricks-sdk-go/config" +) + +// Compute hashes parsed profile values so formatting-only edits do not +// invalidate credentials. +func Compute(values map[string]string) string { + keys := slices.Sorted(maps.Keys(values)) + var serialized []byte + + for _, key := range keys { + value := values[key] + serialized = binary.AppendUvarint(serialized, uint64(len(key))) + serialized = append(serialized, key...) + serialized = binary.AppendUvarint(serialized, uint64(len(value))) + serialized = append(serialized, value...) + } + + sum := sha256.Sum256(serialized) + + return hex.EncodeToString(sum[:]) +} + +// FromFile hashes every parsed key and value in the named profile section. +func FromFile(configFilePath, profileName string) (string, error) { + file, err := config.LoadFile(configFilePath) + if err != nil { + return "", err + } + + section, err := file.GetSection(profileName) + if err != nil { + return "", err + } + + return Compute(section.KeysHash()), nil +} diff --git a/libs/databrickscfg/profilehash/profilehash_test.go b/libs/databrickscfg/profilehash/profilehash_test.go new file mode 100644 index 00000000000..be37b940565 --- /dev/null +++ b/libs/databrickscfg/profilehash/profilehash_test.go @@ -0,0 +1,77 @@ +package profilehash + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCompute verifies that profile fingerprints are independent of map iteration order +// but change when a parsed profile value changes. +func TestCompute(t *testing.T) { + base := map[string]string{ + "host": "https://workspace.example.com", + "auth_type": "databricks-cli", + "custom_key": "custom-value", + } + tests := []struct { + name string + values map[string]string + wantEqual bool + }{ + { + name: "map order does not matter", + values: map[string]string{ + "custom_key": "custom-value", + "auth_type": "databricks-cli", + "host": "https://workspace.example.com", + }, + wantEqual: true, + }, + { + name: "changed value changes fingerprint", + values: map[string]string{ + "host": "https://workspace.example.com", + "auth_type": "databricks-cli", + "custom_key": "changed-value", + }, + wantEqual: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.wantEqual { + assert.Equal(t, Compute(base), Compute(tt.values)) + return + } + + assert.NotEqual(t, Compute(base), Compute(tt.values)) + }) + } +} + +// TestFromFileIgnoresFormatting verifies that formatting-only profile edits do not +// invalidate cached credentials. +func TestFromFileIgnoresFormatting(t *testing.T) { + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(`[TEST] +host = https://workspace.example.com +scopes = jobs +`), 0o600)) + first, err := FromFile(path, "TEST") + require.NoError(t, err) + + require.NoError(t, os.WriteFile(path, []byte(`[TEST] +# Reordering keys and adding comments does not change profile semantics. +scopes=jobs +host=https://workspace.example.com +`), 0o600)) + second, err := FromFile(path, "TEST") + require.NoError(t, err) + + assert.Equal(t, first, second) +}