From 4d18eff914038c02a36daf0a3ea560a763fcd8a8 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 27 May 2026 13:51:47 +0200 Subject: [PATCH 1/5] auth: clear error for PAT profile on SPOG without workspace_id Personal access tokens are workspace-scoped. When a PAT profile points at a SPOG host (account-scoped OIDC discovery) without `workspace_id`, the SDK can't add the routing identifier; the request lands on the account-plane where PATs aren't accepted, and the user sees the opaque "Credential was not sent or was of an unsupported type for this API" error from the auth endpoint. Detect this combination (auth_type=pat, SPOG discovery signal, workspace_id empty) up front in `workspaceClientOrPrompt` and return a message that names the profile, explains the routing constraint, and points at the fix: add `workspace_id = ` for the workspace the token was minted in. Co-authored-by: Isaac --- cmd/root/auth.go | 26 ++++++++++++++++ cmd/root/auth_test.go | 69 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 8b9bfd0810..3f3e66f17e 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -52,6 +52,24 @@ func initProfileFlag(cmd *cobra.Command) { cmd.RegisterFlagCompletionFunc("profile", profile.ProfileCompletion) } +// isPATOnSPOGWithoutWorkspaceID reports whether the resolved config is a PAT +// profile pointing at a SPOG host with no workspace_id set. The SDK strips the +// routing identifier from the request, which lands on the account-plane where +// PATs aren't accepted. +func isPATOnSPOGWithoutWorkspaceID(cfg *config.Config) bool { + return cfg.AuthType == auth.AuthTypePat && + cfg.WorkspaceID == "" && + auth.HasUnifiedHostSignal(cfg.DiscoveryURL) +} + +// patSPOGNoWorkspaceIDError describes the configuration gap and how to fix it. +func patSPOGNoWorkspaceIDError(profileName string) error { + if profileName == "" { + return errors.New("personal access token (PAT) auth on this host requires a workspace_id; PATs are workspace-scoped, but no workspace_id is set. Add workspace_id = to the profile (or set DATABRICKS_WORKSPACE_ID) to the workspace the token was minted in") + } + return fmt.Errorf("profile %q uses PAT auth on a SPOG host but is missing workspace_id; PATs are workspace-scoped, so the request can't be routed. Edit the profile to add workspace_id = matching the workspace the token was minted in", profileName) +} + func profileFlagValue(cmd *cobra.Command) (string, bool) { profileFlag := cmd.Flag("profile") if profileFlag == nil { @@ -189,6 +207,14 @@ func MustAccountClient(cmd *cobra.Command, args []string) error { // Helper function to create a workspace client or prompt once if the given configuration is not valid. func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPrompt bool) (*databricks.WorkspaceClient, error) { w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg)) + if err == nil && isPATOnSPOGWithoutWorkspaceID(cfg) { + // PATs are workspace-scoped. On a SPOG host without workspace_id the + // SDK can't add the routing identifier, the backend treats the call as + // account-plane, and PATs aren't accepted there. The result is an + // opaque "Credential was not sent" error from the auth endpoint; + // rewrite up front so the user sees what's actually wrong. + return nil, patSPOGNoWorkspaceIDError(cfg.Profile) + } if err == nil { err = w.Config.Authenticate(emptyHttpRequest(ctx)) } diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index c75fd0209f..45075aab4b 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -454,6 +454,75 @@ func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) { assert.ErrorIs(t, err, databricks.ErrNotAccountClient) } +func TestIsPATOnSPOGWithoutWorkspaceID(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + want bool + }{ + { + name: "pat on spog without workspace_id", + cfg: &config.Config{ + AuthType: "pat", + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server", + }, + want: true, + }, + { + name: "pat on spog with workspace_id is fine", + cfg: &config.Config{ + AuthType: "pat", + WorkspaceID: "12345", + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server", + }, + want: false, + }, + { + name: "pat on classic workspace host is fine", + cfg: &config.Config{ + AuthType: "pat", + DiscoveryURL: "https://workspace.example.test/oidc/.well-known/oauth-authorization-server", + }, + want: false, + }, + { + name: "u2m on spog is not affected (handled by other paths)", + cfg: &config.Config{ + AuthType: "databricks-cli", + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isPATOnSPOGWithoutWorkspaceID(tt.cfg)) + }) + } +} + +func TestWorkspaceClientOrPromptRejectsPATOnSPOGWithoutWorkspaceID(t *testing.T) { + testutil.CleanupEnvironment(t) + t.Setenv("PATH", "") + + cfg := &config.Config{ + Host: "https://spog.example.test/", + AccountID: "abc-123", + Token: "dapi-fake", + Profile: "spog-pat", + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc-123/.well-known/oauth-authorization-server", + AuthType: "pat", + HTTPTransport: noNetworkTransport, + } + + w, err := workspaceClientOrPrompt(t.Context(), cfg, false) + assert.Nil(t, w) + require.Error(t, err) + assert.Contains(t, err.Error(), `profile "spog-pat"`) + assert.Contains(t, err.Error(), "workspace_id") + assert.Contains(t, err.Error(), "PAT") +} + func TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) { testutil.CleanupEnvironment(t) t.Setenv("PATH", "") From 32ce366b724ac137d8ac6a32d4f3e97befa8ef50 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 27 May 2026 15:12:41 +0200 Subject: [PATCH 2/5] auth: cover the bug bash .databrickscfg shape for PAT-on-SPOG detector GPT review pointed out the existing test seeded AuthType and DiscoveryURL manually, so it didn't prove the detector still fires when those fields come from the SDK during NewWorkspaceClient (the realistic flow). Add a test that writes a .databrickscfg with only `host` and `token` (matching the bug bash repro), points the host at an httptest server serving SPOG-style .well-known metadata, and runs through the public workspaceClientOrPrompt entry point. AuthType is populated by the SDK credential probe; DiscoveryURL is populated by host metadata resolution. The detector still catches the case. Co-authored-by: Isaac --- cmd/root/auth_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index 45075aab4b..dad211afbb 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -2,7 +2,9 @@ package root import ( "context" + "fmt" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -523,6 +525,42 @@ func TestWorkspaceClientOrPromptRejectsPATOnSPOGWithoutWorkspaceID(t *testing.T) assert.Contains(t, err.Error(), "PAT") } +// TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile exercises the +// real .databrickscfg shape from the bug bash: `host` + `token` only, no +// `auth_type`, no `workspace_id`. The SDK populates AuthType during +// NewWorkspaceClient via its credential probe, so the PAT-on-SPOG detector +// must keep working after going through that path. +func TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile(t *testing.T) { + testutil.CleanupEnvironment(t) + t.Setenv("PATH", "") + + // Mock .well-known/databricks-config to return an account-scoped OIDC + // endpoint so the SDK populates cfg.DiscoveryURL with the SPOG signal. + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/databricks-config", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"account_id":"abc-123","oidc_endpoint":"https://spog.example.test/oidc/accounts/abc-123"}`)) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(configFile, fmt.Appendf(nil, ` +[spog-pat] +host = %s +token = dapi-fake +`, server.URL), 0o600)) + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + + cfg := &config.Config{Profile: "spog-pat"} + w, err := workspaceClientOrPrompt(t.Context(), cfg, false) + assert.Nil(t, w) + require.Error(t, err) + assert.Contains(t, err.Error(), `profile "spog-pat"`) + assert.Contains(t, err.Error(), "workspace_id") + assert.Contains(t, err.Error(), "PAT") +} + func TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) { testutil.CleanupEnvironment(t) t.Setenv("PATH", "") From 2a8c280ceb9df881c99df9a8149234f0f1ddd52b Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 27 May 2026 16:35:24 +0200 Subject: [PATCH 3/5] test: bind IPv4 in PAT-SPOG test so Windows runner stops panicking The Windows GitHub runner doesn't have IPv6 configured. httptest.NewServer tried 127.0.0.1 first and fell through to "[::1]:0" on retry, then panicked: "listen tcp6 [::1]:0: socket: The requested service provider could not be loaded or initialized." Build the listener directly with net.Listen("tcp4", "127.0.0.1:0") and hand it to httptest. Co-authored-by: Isaac --- cmd/root/auth_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index dad211afbb..395d491c2b 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -3,6 +3,7 @@ package root import ( "context" "fmt" + "net" "net/http" "net/http/httptest" "os" @@ -541,7 +542,13 @@ func TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"account_id":"abc-123","oidc_endpoint":"https://spog.example.test/oidc/accounts/abc-123"}`)) }) - server := httptest.NewServer(mux) + // Bind explicitly to IPv4 — httptest.NewServer falls through to IPv6 if + // the default listener fails, and the Windows GitHub runner doesn't have + // IPv6 configured, so the panic message is "listen tcp6 [::1]:0: socket". + ln, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + server := &httptest.Server{Listener: ln, Config: &http.Server{Handler: mux}} + server.Start() t.Cleanup(server.Close) configFile := filepath.Join(t.TempDir(), ".databrickscfg") From 9aa510113d1ebbb21ad4f9b2a049e32bf5a91774 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 27 May 2026 17:35:18 +0200 Subject: [PATCH 4/5] test: skip CleanupEnvironment in PAT-SPOG config-file test on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fresh Windows failure was a different shape: "listen tcp4 127.0.0.1:0: socket: The requested service provider could not be loaded or initialized." Root cause: testutil.CleanupEnvironment calls os.Clearenv, which on Windows wipes SystemRoot/WINDIR and trips Winsock initialization in the test process. Subsequent net.Listen — including httptest's own listener — then fails. Skip CleanupEnvironment for this test and instead t.Setenv only the Databricks vars we need to clear. The Windows networking stack stays intact and httptest.NewServer works without manual listener wiring. Co-authored-by: Isaac --- cmd/root/auth_test.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index 395d491c2b..c5c2d3f11b 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -3,7 +3,6 @@ package root import ( "context" "fmt" - "net" "net/http" "net/http/httptest" "os" @@ -532,7 +531,15 @@ func TestWorkspaceClientOrPromptRejectsPATOnSPOGWithoutWorkspaceID(t *testing.T) // NewWorkspaceClient via its credential probe, so the PAT-on-SPOG detector // must keep working after going through that path. func TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile(t *testing.T) { - testutil.CleanupEnvironment(t) + // testutil.CleanupEnvironment calls os.Clearenv(), which wipes Windows + // essentials like SystemRoot and breaks Winsock initialization for + // subsequent net.Listen calls. We only need a clean DATABRICKS_CONFIG_FILE + // for this test; set it directly with t.Setenv so the rest of the + // environment (notably the Windows networking stack) keeps working. + t.Setenv("DATABRICKS_AUTH_TYPE", "") + t.Setenv("DATABRICKS_HOST", "") + t.Setenv("DATABRICKS_TOKEN", "") + t.Setenv("DATABRICKS_CONFIG_PROFILE", "") t.Setenv("PATH", "") // Mock .well-known/databricks-config to return an account-scoped OIDC @@ -542,13 +549,7 @@ func TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"account_id":"abc-123","oidc_endpoint":"https://spog.example.test/oidc/accounts/abc-123"}`)) }) - // Bind explicitly to IPv4 — httptest.NewServer falls through to IPv6 if - // the default listener fails, and the Windows GitHub runner doesn't have - // IPv6 configured, so the panic message is "listen tcp6 [::1]:0: socket". - ln, err := net.Listen("tcp4", "127.0.0.1:0") - require.NoError(t, err) - server := &httptest.Server{Listener: ln, Config: &http.Server{Handler: mux}} - server.Start() + server := httptest.NewServer(mux) t.Cleanup(server.Close) configFile := filepath.Join(t.TempDir(), ".databrickscfg") From 5b0a38d3358642b5ba12404719ebf7b462c3b8a1 Mon Sep 17 00:00:00 2001 From: simon Date: Thu, 28 May 2026 15:17:41 +0200 Subject: [PATCH 5/5] auth: address review feedback on PAT-on-SPOG detection Two changes from Mihai's review on #5341: - isPATOnSPOGWithoutWorkspaceID now also matches the legacy "none" sentinel (auth.WorkspaceIDNone), matching how libs/databrickscfg/profile/profiler.go treats it. Without this, a PAT profile that explicitly set workspace_id = none would still hit the opaque "Credential was not sent" error from the auth endpoint. - Reworded the empty-profile branch of patSPOGNoWorkspaceIDError. The old phrasing read "to the profile ... to the workspace" once the parenthetical was elided. New phrasing keeps the parens grouped with the option that introduces them. Co-authored-by: Isaac --- cmd/root/auth.go | 8 +++++--- cmd/root/auth_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 3f3e66f17e..230a098cac 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -55,17 +55,19 @@ func initProfileFlag(cmd *cobra.Command) { // isPATOnSPOGWithoutWorkspaceID reports whether the resolved config is a PAT // profile pointing at a SPOG host with no workspace_id set. The SDK strips the // routing identifier from the request, which lands on the account-plane where -// PATs aren't accepted. +// PATs aren't accepted. The legacy "none" sentinel (auth.WorkspaceIDNone) is +// treated as empty here, matching the convention used elsewhere in the repo +// (e.g. libs/databrickscfg/profile/profiler.go). func isPATOnSPOGWithoutWorkspaceID(cfg *config.Config) bool { return cfg.AuthType == auth.AuthTypePat && - cfg.WorkspaceID == "" && + (cfg.WorkspaceID == "" || cfg.WorkspaceID == auth.WorkspaceIDNone) && auth.HasUnifiedHostSignal(cfg.DiscoveryURL) } // patSPOGNoWorkspaceIDError describes the configuration gap and how to fix it. func patSPOGNoWorkspaceIDError(profileName string) error { if profileName == "" { - return errors.New("personal access token (PAT) auth on this host requires a workspace_id; PATs are workspace-scoped, but no workspace_id is set. Add workspace_id = to the profile (or set DATABRICKS_WORKSPACE_ID) to the workspace the token was minted in") + return errors.New("personal access token (PAT) auth on this host requires a workspace_id; PATs are workspace-scoped, but no workspace_id is set. Add workspace_id = (or set DATABRICKS_WORKSPACE_ID) to the profile associated with this PAT token") } return fmt.Errorf("profile %q uses PAT auth on a SPOG host but is missing workspace_id; PATs are workspace-scoped, so the request can't be routed. Edit the profile to add workspace_id = matching the workspace the token was minted in", profileName) } diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index c5c2d3f11b..e105eddfc8 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/databricks/cli/internal/testutil" + "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go" @@ -479,6 +480,15 @@ func TestIsPATOnSPOGWithoutWorkspaceID(t *testing.T) { }, want: false, }, + { + name: "pat on spog with legacy 'none' sentinel is treated as missing", + cfg: &config.Config{ + AuthType: "pat", + WorkspaceID: auth.WorkspaceIDNone, + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server", + }, + want: true, + }, { name: "pat on classic workspace host is fine", cfg: &config.Config{