Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
57f8b47
Reject an empty host at the login host prompt
maksim-zuev-db Oct 27, 2026
81bdb8f
Add a changelog fragment for the empty host prompt fix
maksim-zuev-db Oct 27, 2026
9df79c6
Trim spaces around the host entered at the login host prompt
maksim-zuev-db Oct 27, 2026
5a1041f
Reject a host that is empty after normalization
maksim-zuev-db Oct 27, 2026
00f08ce
Check the screen in the empty host prompt test
maksim-zuev-db Oct 27, 2026
63017a4
Show a blank prompt glyph until invalid input is edited
maksim-zuev-db Oct 27, 2026
f6d384f
Update the changelog fragment for the review fixes
maksim-zuev-db Oct 27, 2026
954e9ac
Check for an empty host in one place, including --skip-workspace
maksim-zuev-db Oct 27, 2026
5604deb
Rename normalizeHost to normalizeLoginHost
maksim-zuev-db Oct 27, 2026
fe365cb
Revert the blank prompt glyph
maksim-zuev-db Oct 30, 2026
ab595b8
List all query params in the normalizeLoginHost comment
maksim-zuev-db Oct 30, 2026
6317d0a
Name the empty host test cases and keep paired tests together
maksim-zuev-db Oct 30, 2026
f206240
Trim spaces around the host in normalizeLoginHost
maksim-zuev-db Oct 30, 2026
d091842
Validate the normalized host at the login host prompt
maksim-zuev-db Oct 30, 2026
1f1a485
Add acceptance tests for an invalid host in auth login and auth token
maksim-zuev-db Oct 30, 2026
4e0b261
Drop the prompt baseline tests added for the reverted glyph change
maksim-zuev-db Nov 2, 2026
da27a3a
Reject a login host without a hostname
maksim-zuev-db Nov 2, 2026
7d6471d
Reject an invalid login host before the profile name prompt
maksim-zuev-db Nov 2, 2026
02ec972
Reject a login host that is not a URL
maksim-zuev-db Nov 2, 2026
df05549
Reject a login host with a scheme that is not a URL
maksim-zuev-db Nov 2, 2026
7d24efd
Rename the skip workspace invalid host test
maksim-zuev-db Nov 2, 2026
35c5fa9
Simplify the login host hostname check
maksim-zuev-db Nov 2, 2026
de8d027
Require an http or https scheme in the login host
maksim-zuev-db Nov 3, 2026
0810d37
Normalize the login host before the profile name prompt
maksim-zuev-db Nov 3, 2026
b4fe7ae
Keep the login host unchanged when it is rejected
maksim-zuev-db Nov 3, 2026
46a6421
Show an example host in the invalid login host error
maksim-zuev-db Nov 3, 2026
e040201
Use the picker label in the changelog fragment
maksim-zuev-db Nov 3, 2026
b501336
Only trim the login host before the profile name prompt
maksim-zuev-db Nov 3, 2026
27671c6
refactor
maksim-zuev-db Nov 3, 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
1 change: 1 addition & 0 deletions .nextchanges/cli/login-reject-empty-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* `databricks auth login` and `databricks auth token` now reject a host that is not a URL with a hostname, such as `/`, `?o=123` or `https://`, instead of falling back to `DATABRICKS_HOST` or failing later. An empty host after "Enter a host URL manually" in the login profile picker no longer starts discovery login. ([#7195](https://github.com/databricks/cli/pull/7195))
1 change: 1 addition & 0 deletions acceptance/cmd/auth/login/invalid-host/out.test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cloud = false
6 changes: 6 additions & 0 deletions acceptance/cmd/auth/login/invalid-host/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

>>> musterr [CLI] auth login --host ?o=123 --profile test
Error: invalid host "?o=123". Expected a URL like https://<databricks-instance>.cloud.databricks.com

>>> musterr [CLI] auth login --host https:// --profile test
Error: invalid host "https://". Expected a URL like https://<databricks-instance>.cloud.databricks.com
9 changes: 9 additions & 0 deletions acceptance/cmd/auth/login/invalid-host/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
sethome "./home"

# A host that is empty without its query params is rejected before login
# can fall back to discovery or DATABRICKS_HOST.
trace musterr $CLI auth login --host '?o=123' --profile test

# A host without a hostname is rejected instead of being read as a host
# named "https".
trace musterr $CLI auth login --host 'https://' --profile test
3 changes: 3 additions & 0 deletions acceptance/cmd/auth/login/invalid-host/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Ignore = [
"home"
]
1 change: 1 addition & 0 deletions acceptance/cmd/auth/token/invalid-host/out.test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cloud = false
6 changes: 6 additions & 0 deletions acceptance/cmd/auth/token/invalid-host/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

>>> musterr [CLI] auth token --host ?o=123
Error: invalid host "?o=123". Expected a URL like https://<databricks-instance>.cloud.databricks.com

>>> musterr [CLI] auth token --host https://
Error: invalid host "https://". Expected a URL like https://<databricks-instance>.cloud.databricks.com
13 changes: 13 additions & 0 deletions acceptance/cmd/auth/token/invalid-host/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
sethome "./home"

unset DATABRICKS_HOST
unset DATABRICKS_TOKEN
unset DATABRICKS_CONFIG_PROFILE

# A host that is empty without its query params is rejected instead of
# falling back to DATABRICKS_HOST.
trace musterr $CLI auth token --host '?o=123'

# A host without a hostname is rejected instead of being read as a host
# named "https".
trace musterr $CLI auth token --host 'https://'
3 changes: 3 additions & 0 deletions acceptance/cmd/auth/token/invalid-host/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Ignore = [
"home"
]
15 changes: 13 additions & 2 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"

"github.com/databricks/cli/libs/auth"
"github.com/databricks/cli/libs/cmdio"
Expand Down Expand Up @@ -50,10 +51,20 @@ func promptForHost(ctx context.Context) (string, error) {
// promptui's screenbuf does not account for terminal line wrapping, and a
// long "label + value" line that wraps causes each keystroke to leave a
// stale render on screen instead of overwriting the previous one.
cmdio.LogString(ctx, "Example: https://<databricks-instance>.cloud.databricks.com")
return cmdio.RunPrompt(ctx, cmdio.PromptOptions{
cmdio.LogString(ctx, "Example: "+exampleHost)
result, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{
Label: "Databricks host",
Validate: func(input string) error {
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
if strings.TrimSpace(input) == "" {
return errors.New("host is required")
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
}
// Reject hosts that setHost would reject after the prompt,
// e.g. "/" or "?o=123", while the user can still edit them.
_, err := parseLoginHost(input)
return err
},
})
return strings.TrimSpace(result), err
}

func promptForAccountID(ctx context.Context) (string, error) {
Expand Down
26 changes: 26 additions & 0 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package auth
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/cmdctx"
"github.com/databricks/cli/libs/cmdio/cmdiotest/termtest"
"github.com/databricks/cli/libs/databrickscfg/profile"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -141,3 +143,27 @@ func TestProfileHostCompatibleViaCobra(t *testing.T) {
assert.NotContains(t, err.Error(), "conflicts with --host")
}
}

func TestPromptForHostRejectsEmptyHost(t *testing.T) {
tm := termtest.Run(t, promptForHost)
tm.WaitFor("Databricks host")
tm.Type(termtest.KeyEnter)
tm.Golden("01-empty")
tm.Type(" " + termtest.KeyEnter)
tm.Golden("02-spaces")
tm.Type("https://workspace.test " + termtest.KeyEnter)
host, err := tm.Result()
require.NoError(t, err)
assert.Equal(t, "https://workspace.test", host)
}

func TestPromptForHostRejectsHostEmptyAfterNormalization(t *testing.T) {
tm := termtest.Run(t, promptForHost)
tm.WaitFor("Databricks host")
tm.Type("?o=123" + termtest.KeyEnter)
tm.Golden("01-query-params")
tm.Type(strings.Repeat(termtest.KeyBackspace, len("?o=123")) + "https://workspace.test/?o=123" + termtest.KeyEnter)
host, err := tm.Result()
require.NoError(t, err)
assert.Equal(t, "https://workspace.test/?o=123", host)
}
106 changes: 74 additions & 32 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
"runtime"
"slices"
"strconv"
Expand Down Expand Up @@ -246,6 +247,17 @@ a new profile is created.
}
}

// setHost normalizes the host only after the keyring probe and the
// profile name prompt, so reject an invalid host before them. Only trim
// it here: an account ID from the query params would make the prompt
// suggest ACCOUNT-<id> for a workspace login.
if authArguments.Host != "" {
if err := normalizeLoginHost(&auth.AuthArguments{Host: authArguments.Host}); err != nil {
return err
}
authArguments.Host = strings.TrimSpace(authArguments.Host)
}
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved

if err := validateDeviceCodeCompletionFlags(cmd); err != nil {
return err
}
Expand Down Expand Up @@ -315,11 +327,6 @@ a new profile is created.
if err != nil {
return err
}
// Fail here rather than prompting for the host again after
// the profile name.
if useDeviceCode && host == "" {
return errDeviceCodeNeedsHost
}
authArguments.Host = host
case profilePickerCreateNew:
// Reject the names of existing profiles, including those the
Expand All @@ -339,13 +346,8 @@ a new profile is created.
// only populated by setHostAndAccountId further down, which runs after
// the profile-name prompt.
if skipWorkspace && profileName == "" && authArguments.Host != "" && authArguments.AccountID == "" {
params := auth.ExtractHostQueryParams(authArguments.Host)
authArguments.Host = params.Host
if authArguments.AccountID == "" {
authArguments.AccountID = params.AccountID
}
if authArguments.WorkspaceID == "" {
authArguments.WorkspaceID = params.WorkspaceID
if err := normalizeLoginHost(authArguments); err != nil {
return err
}
runHostDiscovery(ctx, authArguments)
}
Expand Down Expand Up @@ -417,15 +419,8 @@ a new profile is created.
// Check the host once it is resolved, before setAccountID prompts for
// an account ID for account and unified hosts. A workspace without
// device code login enabled is rejected later by deviceCodeLogin.
if useDeviceCode {
// The host prompt accepts an empty answer. Without this check,
// login would resolve the host from DATABRICKS_HOST instead.
if authArguments.Host == "" {
return errDeviceCodeNeedsHost
}
if !isDeviceCodeSupportedForHost(authArguments.Host, authArguments.DiscoveryURL) {
return errDeviceCodeWorkspaceOnly
}
if useDeviceCode && !isDeviceCodeSupportedForHost(authArguments.Host, authArguments.DiscoveryURL) {
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
return errDeviceCodeWorkspaceOnly
}
err = setAccountID(ctx, existingProfile, authArguments)
if err != nil {
Expand Down Expand Up @@ -629,6 +624,62 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile,
return setAccountID(ctx, existingProfile, authArguments)
}

var errInvalidHost = errors.New("invalid host")

const exampleHost = "https://<databricks-instance>.cloud.databricks.com"

// normalizeLoginHost strips the trailing slash and the query params from the
// host and moves the workspace and account ID query params into WorkspaceID and
// AccountID unless they are set.
// It fails if the host is not a URL with a hostname, e.g. for "/", "?o=123",
// "https://" or "https://my workspace.test", because login would then use discovery
// or DATABRICKS_HOST, connect to a host named "https", or fail later.
func normalizeLoginHost(authArguments *auth.AuthArguments) error {
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
params, err := parseLoginHost(authArguments.Host)
if err != nil {
// The host may come from a profile or DATABRICKS_HOST, so say what
// shape is expected.
return fmt.Errorf("%w. Expected a URL like %s", err, exampleHost)
}
authArguments.Host = params.Host
if authArguments.WorkspaceID == "" {
authArguments.WorkspaceID = params.WorkspaceID
}
if authArguments.AccountID == "" {
authArguments.AccountID = params.AccountID
}
return nil
}

// parseLoginHost trims host and extracts its query params. It returns
// errInvalidHost if host is not a URL with a hostname.
func parseLoginHost(host string) (auth.HostParams, error) {
// Extract query parameters from the host URL (?o=workspace_id, ?a=account_id).
// URL params from explicit --host override stale profile values.
params := auth.ExtractHostQueryParams(strings.TrimSuffix(strings.TrimSpace(host), "/"))
if !hasHostname(params.Host) {
return auth.HostParams{}, fmt.Errorf("%w %q", errInvalidHost, host)
}
return params, nil
}

// hasHostname reports whether host, with or without a scheme, is an http or
// https URL with a hostname.
func hasHostname(host string) bool {
u, err := url.Parse(host)
// url.Parse can't read a host without a scheme, e.g. "localhost:8080", so
// check it as an https host. A host with "//" has a scheme, possibly a
// malformed one like in "https//x.test", so it is checked as is.
if (err != nil || !isHTTPScheme(u.Scheme)) && !strings.Contains(host, "//") {
u, err = url.Parse("https://" + host)
}
return err == nil && isHTTPScheme(u.Scheme) && u.Hostname() != ""
}

func isHTTPScheme(scheme string) bool {
return scheme == "http" || scheme == "https"
}

// Sets the host in the persistentAuth object based on the provided arguments and flags.
// Follows the following precedence:
// 1. [HOST] (first positional argument) or --host flag. Error if both are specified.
Expand Down Expand Up @@ -660,17 +711,8 @@ func setHost(ctx context.Context, existingProfile *profile.Profile, authArgument
}
}

authArguments.Host = strings.TrimSuffix(authArguments.Host, "/")

// Extract query parameters from the host URL (?o=workspace_id, ?a=account_id).
// URL params from explicit --host override stale profile values.
params := auth.ExtractHostQueryParams(authArguments.Host)
authArguments.Host = params.Host
if authArguments.WorkspaceID == "" {
authArguments.WorkspaceID = params.WorkspaceID
}
if authArguments.AccountID == "" {
authArguments.AccountID = params.AccountID
if err := normalizeLoginHost(authArguments); err != nil {
return err
}

// Inherit workspace_id from the existing profile AFTER URL param extraction.
Expand Down
25 changes: 23 additions & 2 deletions cmd/auth/login_device_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,10 @@ func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) {
tm := startDeviceCodeLogin(t, "", "--profile", "newprof")
tm.WaitFor("Databricks host")
tm.Type(termtest.KeyEnter)
tm.WaitFor(">> host is required")
tm.Type(termtest.KeyCtrlC)
_, err := tm.Result()
assert.ErrorIs(t, err, errDeviceCodeNeedsHost)
assert.EqualError(t, err, "^C")
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
}

func TestLoginDeviceCodePickerEmptyHost(t *testing.T) {
Expand All @@ -323,8 +325,10 @@ func TestLoginDeviceCodePickerEmptyHost(t *testing.T) {
tm.Type(termtest.KeyDown + termtest.KeyEnter)
tm.WaitFor("Databricks host")
tm.Type(termtest.KeyEnter)
tm.WaitFor(">> host is required")
tm.Type(termtest.KeyCtrlC)
_, err := tm.Result()
assert.ErrorIs(t, err, errDeviceCodeNeedsHost)
assert.EqualError(t, err, "^C")
}

func TestLoginDeviceCodeCreateNewProfileRejectsHiddenProfileName(t *testing.T) {
Expand All @@ -350,6 +354,23 @@ account_id = acct-123
assert.EqualError(t, err, "^C")
}

func TestLoginDeviceCodeHostEmptyAfterNormalization(t *testing.T) {
tests := []struct {
name string
host string
}{
{name: "slash", host: "/"},
{name: "query params only", host: "?o=123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tm := startDeviceCodeLogin(t, "", "--profile", "newprof", "--host", tt.host)
_, err := tm.Result()
assert.ErrorIs(t, err, errInvalidHost)
})
}
}

func TestMatchDeviceCodeProfiles(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading