Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .nextchanges/cli/profile-fingerprint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Require a new OAuth login before reusing cached credentials after the corresponding profile configuration changes. ([#6427](https://github.com/databricks/cli/pull/6427))
23 changes: 23 additions & 0 deletions acceptance/bin/profile_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/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 = dict(argument.split("=", maxsplit=1) for argument in 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())
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@ default_profile = logfood
EOF

mkdir -p "./home/.databricks"
profile_fingerprint=$(profile_fingerprint.py \
"account_id=stale-account" \
"auth_type=databricks-cli" \
"host=${DATABRICKS_HOST}")

# Host cache keys can be shared by profiles, so only the profile entry is bound.
cat > "./home/.databricks/token-cache.json" <<EOF
{
"version": 1,
"tokens": {
"logfood": {
"access_token": "logfood-cached-token",
"token_type": "Bearer"
"token_type": "Bearer",
"profile_fingerprint": "${profile_fingerprint}"
},
"${DATABRICKS_HOST}": {
"access_token": "logfood-host-token",
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions acceptance/cmd/auth/token/legacy-profile-fingerprint/script
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Ignore = [
"home",
]
2 changes: 2 additions & 0 deletions acceptance/cmd/auth/token/profile-change/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions acceptance/cmd/auth/token/profile-change/output.txt
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions acceptance/cmd/auth/token/profile-change/script
Original file line number Diff line number Diff line change
@@ -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.bak '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
4 changes: 4 additions & 0 deletions acceptance/cmd/auth/token/profile-change/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Ignore = [
"home",
"token-cache.before.json",
]
7 changes: 6 additions & 1 deletion acceptance/cmd/auth/token/script.prepare
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ 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 \
"auth_type=databricks-cli" \
"host=$DATABRICKS_HOST_ORIG")
cat > "./home/.databricks/token-cache.json" <<ENDCACHE
{
"version": 1,
Expand All @@ -23,7 +27,8 @@ setup_test_token_cache() {
"access_token": "cached-access-token",
"token_type": "Bearer",
"refresh_token": "test-refresh-token",
"expiry": "2099-01-01T00:00:00Z"
"expiry": "2099-01-01T00:00:00Z",
"profile_fingerprint": "$profile_fingerprint"
}
}
}
Expand Down
16 changes: 14 additions & 2 deletions cmd/auth/in_memory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import (
)

type inMemoryStore struct {
Tokens map[string]*oauth2.Token
Tokens map[string]*oauth2.Token
Fingerprints map[string]string
}

// Lookup returns a copy to match real (file-backed) cache behavior, where
Expand All @@ -19,21 +20,32 @@ func (i *inMemoryStore) Lookup(key string) (storage.Entry, error) {
return storage.Entry{}, storage.ErrNotFound
}
cp := *token
return storage.Entry{Token: &cp}, nil
return storage.Entry{
Token: &cp,
ProfileFingerprint: i.Fingerprints[key],
}, nil
}

// Put stores a copy to prevent callers from mutating cached entries after
// put returns (mirrors file-backed cache semantics).
func (i *inMemoryStore) Put(key string, e storage.Entry) error {
cp := *e.Token
i.Tokens[key] = &cp

if i.Fingerprints == nil {
i.Fingerprints = make(map[string]string)
}

i.Fingerprints[key] = e.ProfileFingerprint

return nil
}

// Delete deletes the entry under key. Deleting a missing entry is not
// an error.
func (i *inMemoryStore) Delete(key string) error {
delete(i.Tokens, key)
delete(i.Fingerprints, key)
return nil
}

Expand Down
37 changes: 33 additions & 4 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ func (d *defaultDiscoveryClient) IntrospectToken(ctx context.Context, host, acce
return auth.IntrospectToken(ctx, host, accessToken, nil)
}

// setTokenProfileFingerprint runs after profile saving because OAuth-dependent
// workspace and compute selection can change the final profile contents.
Comment on lines +92 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should this be a call site comment? It does not tell me much about the function itself.

func setTokenProfileFingerprint(ctx context.Context, profiler profile.Profiler, tokenStore storage.Store, profileName string) error {
savedProfile, err := loadProfileByName(ctx, profileName, profiler)
if err != nil {
return fmt.Errorf("load saved profile %q: %w", profileName, err)
}
if savedProfile == nil {
return fmt.Errorf("saved profile %q not found", profileName)
}

if err := storage.SetProfileFingerprint(tokenStore, profileName, savedProfile.Fingerprint()); err != nil {
return fmt.Errorf("save profile fingerprint: %w", err)
}

return nil
}

func newLoginCommand(authArguments *auth.AuthArguments) *cobra.Command {
defaultConfigPath := "~/.databrickscfg"
if runtime.GOOS == "windows" {
Expand Down Expand Up @@ -383,7 +401,7 @@ a new profile is created.
// experimental_is_unified_host is no longer written to new profiles.
// Routing now comes from .well-known discovery; stale keys on existing
// profiles are cleaned up via clearKeys above.
err := databrickscfg.SaveToProfile(ctx, &config.Config{
profileConfig := &config.Config{
Profile: profileName,
Host: authArguments.Host,
AuthType: authTypeDatabricksCLI,
Expand All @@ -393,11 +411,16 @@ a new profile is created.
ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"),
ServerlessComputeID: serverlessComputeID,
Scopes: scopesList,
}, clearKeys...)
}
err := databrickscfg.SaveToProfile(ctx, profileConfig, clearKeys...)
if err != nil {
return err
}

if err := setTokenProfileFingerprint(ctx, profile.DefaultProfiler, tokenStore, profileName); err != nil {
return err
}

cmdio.LogString(ctx, fmt.Sprintf("Profile %s was successfully saved", profileName))
}

Expand Down Expand Up @@ -742,22 +765,28 @@ func discoveryLogin(ctx context.Context, in discoveryLoginInputs) error {
"cluster_id",
"serverless_compute_id",
)
err = databrickscfg.SaveToProfile(ctx, &config.Config{
profileConfig := &config.Config{
Profile: in.profileName,
Host: discoveredHost,
AuthType: authTypeDatabricksCLI,
AccountID: accountID,
WorkspaceID: workspaceID,
Scopes: scopesList,
ConfigFile: configFile,
}, clearKeys...)
}

err = databrickscfg.SaveToProfile(ctx, profileConfig, clearKeys...)
if err != nil {
if configFile != "" {
return fmt.Errorf("saving profile %q to %s: %w", in.profileName, configFile, err)
}
return fmt.Errorf("saving profile %q: %w", in.profileName, err)
}

if err := setTokenProfileFingerprint(ctx, profile.DefaultProfiler, in.tokenStore, in.profileName); err != nil {
return err
}

cmdio.LogString(ctx, fmt.Sprintf("Profile %s was successfully saved", in.profileName))
return nil
}
Expand Down
46 changes: 45 additions & 1 deletion cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/databricks/cli/libs/auth/u2m"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/databrickscfg/profile"
"github.com/databricks/cli/libs/databrickscfg/profilehash"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/log"
"github.com/spf13/cobra"
Expand All @@ -30,7 +31,10 @@ import (
// newTestStore returns an in-memory token cache for tests so that
// discoveryLogin and other login helpers don't touch ~/.databricks/token-cache.json.
func newTestStore() storage.Store {
return &inMemoryStore{Tokens: map[string]*oauth2.Token{}}
// Prepopulate the entry because the fake Challenge does not perform the real OAuth cache write.
return &inMemoryStore{Tokens: map[string]*oauth2.Token{
"DISCOVERY": {AccessToken: "test-token"},
}}
}

// logBuffer is a thread-safe bytes.Buffer for capturing log output in tests.
Expand Down Expand Up @@ -806,6 +810,46 @@ func TestDiscoveryLogin_IntrospectionFailureStillSavesProfile(t *testing.T) {
assert.Empty(t, savedProfile.WorkspaceID)
}

// TestDiscoveryLoginStoresSavedProfileFingerprint verifies that discovery login binds
// its cached token to the profile values ultimately written to disk.
func TestDiscoveryLoginStoresSavedProfileFingerprint(t *testing.T) {
home := t.TempDir()
configPath := filepath.Join(home, ".databrickscfg")
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("DATABRICKS_CONFIG_FILE", "")

oauthArg, err := u2m.NewBasicDiscoveryOAuthArgument("DISCOVERY")
require.NoError(t, err)
oauthArg.SetDiscoveredHost("https://workspace.example.com")

tokenStore := newTestStore()
dc := &fakeDiscoveryClient{
oauthArg: oauthArg,
persistentAuth: &fakeDiscoveryPersistentAuth{
token: &oauth2.Token{AccessToken: "test-token"},
},
introspection: &auth.IntrospectionResult{},
}

ctx, _ := cmdio.NewTestContextWithStdout(t.Context())
err = discoveryLogin(ctx, discoveryLoginInputs{
dc: dc,
profileName: "DISCOVERY",
timeout: time.Second,
browserFunc: func(string) error { return nil },
tokenStore: tokenStore,
})
require.NoError(t, err)

fingerprint, err := profilehash.FromFile(configPath, "DISCOVERY")
require.NoError(t, err)
entry, err := tokenStore.Lookup("DISCOVERY")
require.NoError(t, err)

assert.Equal(t, fingerprint, entry.ProfileFingerprint)
}

func TestDiscoveryLogin_AccountIDMismatchWarning(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".databrickscfg")
Expand Down
25 changes: 22 additions & 3 deletions cmd/auth/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,20 +264,34 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) {
if err != nil {
return nil, err
}
allArgs := append([]u2m.PersistentAuthOption{u2m.WithTokenCache(storage.OAuthTokenCache(ctx, args.tokenStore, args.mode))}, args.persistentAuthOpts...)

tokenStore := args.tokenStore
if existingProfile != nil {
if fingerprint := existingProfile.Fingerprint(); fingerprint != "" {
tokenStore = storage.NewProfileFingerprintStore(tokenStore, existingProfile.Name, fingerprint)
}
}

allArgs := append([]u2m.PersistentAuthOption{u2m.WithTokenCache(storage.OAuthTokenCache(ctx, tokenStore, args.mode))}, args.persistentAuthOpts...)
allArgs = append(allArgs, u2m.WithOAuthArgument(oauthArgument))
persistentAuth, err := u2m.NewPersistentAuth(ctx, allArgs...)
if err != nil {
helpMsg := helpfulError(ctx, args.profileName, oauthArgument)
return nil, fmt.Errorf("%w. %s", err, helpMsg)
}

var t *oauth2.Token
if args.forceRefresh {
t, err = persistentAuth.ForceRefreshToken()
} else {
t, err = persistentAuth.Token()
}
if err != nil {
// Fingerprint errors already include the exact login command needed to
// replace the stale grant, so the generic recovery suffix would duplicate it.
if errors.Is(err, storage.ErrProfileChanged) {
return nil, err
}
if errors.Is(err, cache.ErrNotFound) {
// The error returned by the SDK when the token cache doesn't exist or doesn't contain a token
// for the given host changed in SDK v0.77.0: https://github.com/databricks/databricks-sdk-go/pull/1250.
Expand Down Expand Up @@ -450,19 +464,24 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore s
clearKeys := oauthLoginClearKeys()
clearKeys = append(clearKeys, databrickscfg.ExperimentalIsUnifiedHostKey)

err = databrickscfg.SaveToProfile(ctx, &config.Config{
profileConfig := &config.Config{
Profile: profileName,
Host: loginArgs.Host,
AuthType: authTypeDatabricksCLI,
AccountID: loginArgs.AccountID,
WorkspaceID: loginArgs.WorkspaceID,
ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"),
Scopes: scopesList,
}, clearKeys...)
}
err = databrickscfg.SaveToProfile(ctx, profileConfig, clearKeys...)
if err != nil {
return "", nil, err
}

if err := setTokenProfileFingerprint(ctx, profiler, tokenStore, profileName); err != nil {
return "", nil, err
}

cmdio.LogString(ctx, fmt.Sprintf("Profile %s was successfully saved", profileName))

p, err := loadProfileByName(ctx, profileName, profiler)
Expand Down
Loading
Loading