Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f5d474c
Add token introspection client for login.databricks.com discovery
simonfaltum Mar 10, 2026
f951fef
Fix discovery login tests to avoid hanging on PersistentAuth.Challenge
simonfaltum Mar 10, 2026
6b733d6
Improve discovery login: testability, scopes persistence, and error m…
simonfaltum Mar 11, 2026
fcf3ed5
Fix gofumpt and perfsprint lint issues
simonfaltum Mar 11, 2026
4cfbe37
Extract tip constant, reuse splitScopes in token.go, rename test
simonfaltum Mar 11, 2026
7a32dc1
Merge branch 'main' into simonfaltum/login-discovery
simonfaltum Mar 11, 2026
faafbb0
Remove account_id from discovery login profile save
simonfaltum Mar 12, 2026
3014886
Address review: validate flags in discovery mode, warn on account_id …
simonfaltum Mar 12, 2026
55fe3f5
Add tests for account_id mismatch warning in discovery login
simonfaltum Mar 12, 2026
3a392c7
Remove redundant introspection tests
simonfaltum Mar 12, 2026
b4b7147
Use env.Get(ctx, ...) instead of os.Getenv in discoveryLogin
simonfaltum Mar 12, 2026
85fa40c
Replace remaining os.Getenv with env.Get(ctx, ...) in login command
simonfaltum Mar 12, 2026
3f33cce
Validate discovered host is non-empty after discovery login
simonfaltum Mar 13, 2026
7d5624d
Reject --account-id, --workspace-id, --experimental-is-unified-host w…
simonfaltum Mar 13, 2026
c1e0fc4
Fix lint: use errors.New per perfsprint linter rule
simonfaltum Mar 13, 2026
5a21b82
Merge branch 'main' into simonfaltum/login-discovery
simonfaltum Mar 13, 2026
c6391af
Accept HTTP client parameter in IntrospectToken
simonfaltum Mar 16, 2026
28bbb68
Fix discovery login: HTTP client, scopes, stale fields, error messages
simonfaltum Mar 16, 2026
9b683f9
Use SDK config for introspection HTTP client TLS settings
simonfaltum Mar 16, 2026
67512b8
Simplify introspection HTTP client to clone DefaultTransport
simonfaltum Mar 16, 2026
5e91ce3
Merge branch 'main' into simonfaltum/login-discovery
simonfaltum Mar 20, 2026
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
221 changes: 212 additions & 9 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"runtime"
"strings"
"time"
Expand All @@ -16,12 +17,14 @@ import (
"github.com/databricks/cli/libs/databrickscfg/profile"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/exec"
"github.com/databricks/cli/libs/log"
"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/config/experimental/auth/authconv"
"github.com/databricks/databricks-sdk-go/credentials/u2m"
browserpkg "github.com/pkg/browser"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
)

func promptForProfile(ctx context.Context, defaultValue string) (string, error) {
Expand All @@ -45,8 +48,32 @@ const (
minimalDbConnectVersion = "13.1"
defaultTimeout = 1 * time.Hour
authTypeDatabricksCLI = "databricks-cli"
discoveryFallbackTip = "\n\nTip: you can specify a workspace directly with: databricks auth login --host <url>"
)

// discoveryErr wraps an error (or creates a new one) and appends the
// discovery fallback tip so users know they can bypass login.databricks.com.
func discoveryErr(msg string, err error) error {
if err != nil {
return fmt.Errorf("%s: %w%s", msg, err, discoveryFallbackTip)
}
return fmt.Errorf("%s%s", msg, discoveryFallbackTip)
}

type discoveryPersistentAuth interface {
Challenge() error
Token() (*oauth2.Token, error)
Close() error
}

var newDiscoveryOAuthArgument = u2m.NewBasicDiscoveryOAuthArgument

var newDiscoveryPersistentAuth = func(ctx context.Context, opts ...u2m.PersistentAuthOption) (discoveryPersistentAuth, error) {
return u2m.NewPersistentAuth(ctx, opts...)
}

var introspectToken = auth.IntrospectToken

Copy link
Contributor

Choose a reason for hiding this comment

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

If these globals are for tests, can they be injected by arg or interface instead?

Copy link
Contributor

Choose a reason for hiding this comment

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

For example,

  type discoveryClient interface {
        NewOAuthArgument(profileName string) (*u2m.BasicDiscoveryOAuthArgument, error)
        NewPersistentAuth(ctx context.Context, opts ...u2m.PersistentAuthOption) (discoveryPersistentAuth, error)
        IntrospectToken(ctx context.Context, host, accessToken string, httpClient *http.Client) (*auth.IntrospectionResult, error)
  }

And then pass that down into all the functions where you care about it.

Will also reduce the testing boilerplate.

func newLoginCommand(authArguments *auth.AuthArguments) *cobra.Command {
defaultConfigPath := "~/.databrickscfg"
if runtime.GOOS == "windows" {
Expand All @@ -69,9 +96,11 @@ you can refer to the documentation linked below.
GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html


This command requires a Databricks Host URL (using --host or as a positional argument
or implicitly inferred from the specified profile name)
and a profile name (using --profile) to be specified. If you don't specify these
If no host is provided (via --host, as a positional argument, or from an existing
profile), the CLI will open login.databricks.com where you can authenticate and
select a workspace. The workspace URL will be discovered automatically.

A profile name (using --profile) can be specified. If you don't specify these
values, you'll be prompted for values at runtime.

While this command always logs you into the specified host, the runtime behaviour
Expand Down Expand Up @@ -138,6 +167,21 @@ depends on the existing profiles you have set in your configuration file
return err
}

// If no host is available from any source, use the discovery flow
// via login.databricks.com.
if shouldUseDiscovery(authArguments.Host, args, existingProfile) {
if configureCluster {
return errors.New("--configure-cluster requires --host to be specified")
}
if configureServerless {
return errors.New("--configure-serverless requires --host to be specified")
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Why these constraints?

if err := validateDiscoveryFlagCompatibility(cmd); err != nil {
return err
}
return discoveryLogin(ctx, profileName, loginTimeout, scopes, existingProfile, getBrowserFunc(cmd))
}

// Load unified host flags from the profile if not explicitly set via CLI flag
if !cmd.Flag("experimental-is-unified-host").Changed && existingProfile != nil {
authArguments.IsUnifiedHost = existingProfile.IsUnifiedHost
Expand All @@ -157,15 +201,11 @@ depends on the existing profiles you have set in your configuration file
switch {
case scopes != "":
// Explicit --scopes flag takes precedence.
for _, s := range strings.Split(scopes, ",") {
scopesList = append(scopesList, strings.TrimSpace(s))
}
scopesList = splitScopes(scopes)
case existingProfile != nil && existingProfile.Scopes != "":
// Preserve scopes from the existing profile so re-login
// uses the same scopes the user previously configured.
for _, s := range strings.Split(existingProfile.Scopes, ",") {
scopesList = append(scopesList, strings.TrimSpace(s))
}
scopesList = splitScopes(existingProfile.Scopes)
}

oauthArgument, err := authArguments.ToOAuthArgument()
Expand Down Expand Up @@ -400,6 +440,41 @@ func loadProfileByName(ctx context.Context, profileName string, profiler profile
return nil, nil
}

// shouldUseDiscovery returns true if the discovery flow should be used
// (no host available from any source).
func shouldUseDiscovery(hostFlag string, args []string, existingProfile *profile.Profile) bool {
if hostFlag != "" {
return false
}
if len(args) > 0 {
return false
}
if existingProfile != nil && existingProfile.Host != "" {
return false
}
return true
}

// discoveryIncompatibleFlags lists flags that require --host and are incompatible
// with the discovery login flow via login.databricks.com.
var discoveryIncompatibleFlags = []string{
"account-id",
"workspace-id",
"experimental-is-unified-host",
Copy link
Contributor

Choose a reason for hiding this comment

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

There are explicit checks for --configure-cluster and --configure-serverless above.

Can they be included here?

}

// validateDiscoveryFlagCompatibility returns an error if any flags that require
// --host were explicitly set. These flags are meaningless in discovery mode
// and could lead to incorrect profile configuration.
func validateDiscoveryFlagCompatibility(cmd *cobra.Command) error {
for _, name := range discoveryIncompatibleFlags {
if cmd.Flag(name).Changed {
return fmt.Errorf("--%s requires --host to be specified", name)
}
}
return nil
}

// openURLSuppressingStderr opens a URL in the browser while suppressing stderr output.
// This prevents xdg-open error messages from being displayed to the user.
func openURLSuppressingStderr(url string) error {
Expand All @@ -416,6 +491,126 @@ func openURLSuppressingStderr(url string) error {
return browserpkg.OpenURL(url)
}

// discoveryLogin runs the login.databricks.com discovery flow. The user
// authenticates in the browser, selects a workspace, and the CLI receives
// the workspace host from the OAuth callback's iss parameter.
func discoveryLogin(ctx context.Context, profileName string, timeout time.Duration, scopes string, existingProfile *profile.Profile, browserFunc func(string) error) error {
arg, err := newDiscoveryOAuthArgument(profileName)
if err != nil {
return discoveryErr("setting up login.databricks.com", err)
}

scopesList := splitScopes(scopes)
if len(scopesList) == 0 && existingProfile != nil && existingProfile.Scopes != "" {
scopesList = splitScopes(existingProfile.Scopes)
}

opts := []u2m.PersistentAuthOption{
u2m.WithOAuthArgument(arg),
u2m.WithBrowser(browserFunc),
u2m.WithDiscoveryLogin(),
}
if len(scopesList) > 0 {
opts = append(opts, u2m.WithScopes(scopesList))
}

// Apply timeout before creating PersistentAuth so Challenge() respects it.
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

persistentAuth, err := newDiscoveryPersistentAuth(ctx, opts...)
if err != nil {
return discoveryErr("setting up login.databricks.com", err)
}
defer persistentAuth.Close()

cmdio.LogString(ctx, "Opening login.databricks.com in your browser...")
if err := persistentAuth.Challenge(); err != nil {
return discoveryErr("login via login.databricks.com failed", err)
}

discoveredHost := arg.GetDiscoveredHost()
if discoveredHost == "" {
return discoveryErr("login succeeded but no workspace host was discovered", nil)
}

// Get the token for introspection
tok, err := persistentAuth.Token()
if err != nil {
return fmt.Errorf("retrieving token after login: %w", err)
}

// Best-effort introspection for metadata.
httpClient := httpClientForIntrospection()
var workspaceID string
introspection, err := introspectToken(ctx, discoveredHost, tok.AccessToken, httpClient)
if err != nil {
log.Debugf(ctx, "token introspection failed (non-fatal): %v", err)
} else {
// TODO: Save introspection.AccountID once the SDKs are ready to use
// account_id as part of the profile/cache key. Adding it now would break
// existing auth flows that don't expect account_id on workspace profiles.
workspaceID = introspection.WorkspaceID

// Warn if the detected account_id differs from what's already saved in the profile.
if existingProfile != nil && existingProfile.AccountID != "" && introspection.AccountID != "" &&
existingProfile.AccountID != introspection.AccountID {
log.Warnf(ctx, "detected account ID %q differs from existing profile account ID %q",
introspection.AccountID, existingProfile.AccountID)
}
}

configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE")
clearKeys := oauthLoginClearKeys()
// Clear stale routing fields that may exist from a previous login to a
// different host type. Discovery always produces a workspace-level profile.
// workspace_id is cleared and re-written only if introspection returned a
// fresh value, preventing stale IDs from surviving failed introspection.
clearKeys = append(clearKeys,
"account_id",
"workspace_id",
"experimental_is_unified_host",
"cluster_id",
"serverless_compute_id",
)
err = databrickscfg.SaveToProfile(ctx, &config.Config{
Profile: profileName,
Host: discoveredHost,
AuthType: authTypeDatabricksCLI,
WorkspaceID: workspaceID,
Scopes: scopesList,
ConfigFile: configFile,
}, clearKeys...)
Copy link
Contributor

Choose a reason for hiding this comment

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

Worth figuring out: which keys are worth retaining?

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

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

// splitScopes splits a comma-separated scopes string into a trimmed slice.
func splitScopes(scopes string) []string {
if scopes == "" {
return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Superfluous check.

var result []string
for _, s := range strings.Split(scopes, ",") {
scope := strings.TrimSpace(s)
if scope == "" {
continue
}
result = append(result, scope)
}
if len(result) == 0 {
return nil
}
return result
}

// oauthLoginClearKeys returns profile keys that should be explicitly removed
// when performing an OAuth login. Derives auth credential fields dynamically
// from the SDK's ConfigAttributes to stay in sync as new auth methods are added.
Expand Down Expand Up @@ -457,3 +652,11 @@ func getBrowserFunc(cmd *cobra.Command) func(url string) error {
}
}
}

// httpClientForIntrospection returns an *http.Client suitable for the
// best-effort token introspection call. It clones http.DefaultTransport
// to inherit system CA certs, proxy settings, and timeouts.
func httpClientForIntrospection() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
return &http.Client{Transport: transport}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we use the SDK client instead? It is possible other proxies are configured there (eg Kerberos).

}
Loading
Loading