From 04c70cdc6c566aa5382872ef68ca3a6b2a461d7f Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 28 Oct 2026 13:15:01 +0000 Subject: [PATCH 1/3] Add cmdio.IsInputTTY IsPromptSupported tells whether the CLI can render a prompt, so it also depends on stderr, NO_COLOR, TERM=dumb and Git Bash. A command that reads a plain line from stdin only needs to know whether a user can type into it. Co-authored-by: Isaac --- libs/cmdio/io.go | 9 +++++++++ libs/cmdio/io_test.go | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index 2b9b395ce13..7f0dd853e8c 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -83,6 +83,15 @@ func IsPromptSupported(ctx context.Context) bool { return c.capabilities.SupportsPrompt() } +// IsInputTTY reports whether stdin is a terminal, so that a user can type +// input into the command. Unlike IsPromptSupported, it does not depend on +// stderr, NO_COLOR, TERM=dumb or Git Bash, which only matter for rendering +// prompts. +func IsInputTTY(ctx context.Context) bool { + c := fromContext(ctx) + return c.capabilities.stdinIsTTY +} + // IsPagerSupported reports whether stdin, stdout, and stderr are all interactive // terminals. This is the requirement for a full-screen or navigable output // program: unlike IsPromptSupported it also checks stdout, so it returns false diff --git a/libs/cmdio/io_test.go b/libs/cmdio/io_test.go index 23872d5f95c..7c5aad9a6a6 100644 --- a/libs/cmdio/io_test.go +++ b/libs/cmdio/io_test.go @@ -3,6 +3,8 @@ package cmdio import ( "testing" "time" + + "github.com/stretchr/testify/assert" ) func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { @@ -34,3 +36,14 @@ func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { c.releaseTeaProgram() } + +func TestIsInputTTY(t *testing.T) { + // Prompts are not supported without stderr as a terminal and with color, + // but a user can still type into stdin. + ctx := InContext(t.Context(), &cmdIO{capabilities: Capabilities{stdinIsTTY: true}}) + assert.True(t, IsInputTTY(ctx)) + assert.False(t, IsPromptSupported(ctx)) + + ctx = InContext(t.Context(), &cmdIO{capabilities: Capabilities{stderrIsTTY: true, color: true}}) + assert.False(t, IsInputTTY(ctx)) +} From dec6c3b8b0d9cc816b56ccf703cd8e6ff6353061 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 12:38:47 +0000 Subject: [PATCH 2/3] Store pending device code logins in the token store A non-interactive device code login is completed by a later CLI invocation. Entry gets a PendingLogin field that holds the device code and the user code until then, so that the device code stays in the user's token storage instead of being printed or passed on the command line. A pending entry has no token. Co-authored-by: Isaac --- libs/auth/storage/filestore.go | 5 +++-- libs/auth/storage/filestore_test.go | 22 ++++++++++++++++++++++ libs/auth/storage/keyring.go | 3 ++- libs/auth/storage/keyring_test.go | 13 +++++++++++++ libs/auth/storage/memory.go | 4 ++++ libs/auth/storage/memory_test.go | 18 ++++++++++++++++++ libs/auth/storage/storage.go | 19 ++++++++++++++++++- 7 files changed, 80 insertions(+), 4 deletions(-) diff --git a/libs/auth/storage/filestore.go b/libs/auth/storage/filestore.go index d7f13d397d3..18586a68ca4 100644 --- a/libs/auth/storage/filestore.go +++ b/libs/auth/storage/filestore.go @@ -51,6 +51,7 @@ const ( // the historical bare-token format, leaving room for additive sibling fields. type fileEntry struct { *oauth2.Token + PendingLogin *PendingLogin `json:"pending_login,omitempty"` } // tokenStoreFile is the format of the token store file. @@ -107,7 +108,7 @@ 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, PendingLogin: e.PendingLogin} return c.write(f) } @@ -123,7 +124,7 @@ func (c *fileStore) Lookup(key string) (Entry, error) { if !ok { return Entry{}, ErrNotFound } - return Entry{Token: fe.Token}, nil + return Entry{Token: fe.Token, PendingLogin: fe.PendingLogin}, 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..68e847cb4a1 100644 --- a/libs/auth/storage/filestore_test.go +++ b/libs/auth/storage/filestore_test.go @@ -36,6 +36,28 @@ func TestStoreAndLookup(t *testing.T) { assert.Equal(t, ErrNotFound, err) } +func TestStoreAndLookupPendingLogin(t *testing.T) { + f := setup(t) + c, err := NewFileStore(t.Context(), WithFileLocation(f)) + require.NoError(t, err) + pending := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + require.NoError(t, c.Put("x#device-code", Entry{PendingLogin: pending})) + + got, err := c.Lookup("x#device-code") + require.NoError(t, err) + assert.Nil(t, got.Token) + assert.Equal(t, pending, got.PendingLogin) + + raw, err := os.ReadFile(f) + require.NoError(t, err) + assert.JSONEq(t, `{ + "version": 1, + "tokens": { + "x#device-code": {"pending_login": {"device_code": "device-code", "user_code": "WDJBMJHT"}} + } + }`, string(raw)) +} + func TestNoStoreFileReturnsErrNotConfigured(t *testing.T) { l, err := NewFileStore(t.Context(), WithFileLocation(setup(t))) require.NoError(t, err) diff --git a/libs/auth/storage/keyring.go b/libs/auth/storage/keyring.go index e499c00490c..1beccdca3e5 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"` + PendingLogin *PendingLogin `json:"pending_login,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 7418bf7c7e8..77a7ced4779 100644 --- a/libs/auth/storage/keyring_test.go +++ b/libs/auth/storage/keyring_test.go @@ -118,6 +118,19 @@ func TestKeyringStore_Lookup_ReturnsStoredToken(t *testing.T) { assert.Equal(t, "Bearer", got.Token.TokenType) } +func TestKeyringStore_Lookup_ReturnsStoredPendingLogin(t *testing.T) { + backend := newFakeBackend() + c := newTestStore(backend) + + want := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + require.NoError(t, c.Put("my-profile#device-code", Entry{PendingLogin: want})) + + got, err := c.Lookup("my-profile#device-code") + require.NoError(t, err) + assert.Nil(t, got.Token) + assert.Equal(t, want, got.PendingLogin) +} + func TestKeyringStore_Lookup_MissingReturnsCacheErrNotFound(t *testing.T) { backend := newFakeBackend() c := newTestStore(backend) diff --git a/libs/auth/storage/memory.go b/libs/auth/storage/memory.go index 60918968c31..d639cf5f108 100644 --- a/libs/auth/storage/memory.go +++ b/libs/auth/storage/memory.go @@ -29,6 +29,10 @@ func cloneToken(t *oauth2.Token) *oauth2.Token { func cloneEntry(e Entry) Entry { e.Token = cloneToken(e.Token) + if e.PendingLogin != nil { + pending := *e.PendingLogin + e.PendingLogin = &pending + } return e } diff --git a/libs/auth/storage/memory_test.go b/libs/auth/storage/memory_test.go index abccf029248..7c8f4b6109b 100644 --- a/libs/auth/storage/memory_test.go +++ b/libs/auth/storage/memory_test.go @@ -55,6 +55,24 @@ func TestMemoryStore_PutAndLookupUseCopies(t *testing.T) { } } +func TestMemoryStore_PendingLoginUsesCopies(t *testing.T) { + s := NewMemoryStore() + pending := &PendingLogin{DeviceCode: "device-code", UserCode: "WDJBMJHT"} + if err := s.Put("key1", Entry{PendingLogin: pending}); err != nil { + t.Fatalf("Put: %v", err) + } + + pending.DeviceCode = "mutated-after-store" + + got, err := s.Lookup("key1") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if got.PendingLogin.DeviceCode != "device-code" { + t.Fatalf("DeviceCode after store mutation: want %q, got %q", "device-code", got.PendingLogin.DeviceCode) + } +} + func TestMemoryStore_Delete(t *testing.T) { s := NewMemoryStore() if err := s.Put("key1", Entry{Token: &oauth2.Token{AccessToken: "abc"}}); err != nil { diff --git a/libs/auth/storage/storage.go b/libs/auth/storage/storage.go index 3aad20fc645..90253c55dcb 100644 --- a/libs/auth/storage/storage.go +++ b/libs/auth/storage/storage.go @@ -24,8 +24,25 @@ var ErrNotFound = errors.New("token not found") // without changing the Store interface. Backends persist it verbatim and never // interpret its contents. type Entry struct { - // Token is the cached OAuth token. Always set for stored entries. + // Token is the cached OAuth token. Set for every stored entry except a + // pending login. Token *oauth2.Token + + // PendingLogin is set instead of Token for a login that one CLI + // invocation started and a later one completes. + PendingLogin *PendingLogin +} + +// PendingLogin is a device code login that is waiting for the user's +// confirmation code. Keeping the device code in the store means it never +// leaves the machine that started the login. +type PendingLogin struct { + // DeviceCode is exchanged together with the confirmation code for a token. + DeviceCode string `json:"device_code"` + + // UserCode is the code the user enters in the browser. It identifies the + // login when a newer one replaced it under the same key. + UserCode string `json:"user_code"` } // Store is the CLI's token-storage abstraction: a key/value store with no From adfe6a4d8cdbecc00527e0c781b26adcc0048973 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 28 Oct 2026 15:37:47 +0000 Subject: [PATCH 3/3] Add a non-interactive device code login Device code login reads the confirmation code from stdin, which only works when someone can type into the command. An AI agent or a script that runs the login as a one-off command cannot. When stdin is not a terminal, auth login --use-device-code requests the device authorization, stores the device code as a pending login in the token store, and prints the URL and the user code. Running the same command with the hidden --user-code and --confirmation-code flags exchanges the pending device code and then saves the login like any other. The device code never leaves the machine, so a confirmation code that ends up somewhere else, such as an agent's transcript, cannot complete the login elsewhere. The pending login is keyed by the login's cache key with a #device-code suffix, so the current token keeps working until the login completes. A new login for the same target replaces it, and the user code tells a replaced login apart without sending its confirmation code to the server. Co-authored-by: Isaac --- .../non-interactive/concurrent/out.test.toml | 1 + .../non-interactive/concurrent/output.txt | 60 ++++++++ .../non-interactive/concurrent/script | 23 ++++ .../errors/home/.databrickscfg.tmpl | 4 + .../non-interactive/errors/out.test.toml | 1 + .../non-interactive/errors/output.txt | 90 ++++++++++++ .../device-code/non-interactive/errors/script | 56 ++++++++ .../flags/home/.databrickscfg.tmpl | 5 + .../non-interactive/flags/out.databrickscfg | 15 ++ .../non-interactive/flags/out.test.toml | 1 + .../non-interactive/flags/output.txt | 36 +++++ .../device-code/non-interactive/flags/script | 16 +++ .../non-interactive/nominal/out.databrickscfg | 10 ++ .../non-interactive/nominal/out.test.toml | 1 + .../non-interactive/nominal/output.txt | 93 +++++++++++++ .../non-interactive/nominal/script | 35 +++++ .../non-interactive/script.prepare | 13 ++ .../device-code/non-interactive/test.toml | 3 + .../device-code/unsupported-host/output.txt | 2 +- .../login/device-code/unsupported-host/script | 2 +- cmd/auth/README.md | 31 +++++ cmd/auth/login.go | 53 +++++++- cmd/auth/login_device_code.go | 128 ++++++++++++++++++ cmd/auth/login_device_code_test.go | 102 ++++++++++++-- docs/device-code-login.md | 59 +++++++- 25 files changed, 819 insertions(+), 21 deletions(-) create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/concurrent/out.test.toml create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/concurrent/output.txt create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/concurrent/script create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/errors/home/.databrickscfg.tmpl create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/errors/out.test.toml create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/errors/output.txt create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/errors/script create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/flags/home/.databrickscfg.tmpl create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/flags/out.databrickscfg create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/flags/out.test.toml create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/flags/output.txt create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/flags/script create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.databrickscfg create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.test.toml create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/nominal/output.txt create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/nominal/script create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/script.prepare create mode 100644 acceptance/cmd/auth/login/device-code/non-interactive/test.toml diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/out.test.toml b/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/output.txt b/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/output.txt new file mode 100644 index 00000000000..641900bd214 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/output.txt @@ -0,0 +1,60 @@ + +=== A second login for the same profile replaces the first + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code + +>>> update_file.py home/.databricks/token-cache.json WDJBMJHT NEWRCODE + +>>> cache_keys +[ + "test#device-code" +] + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: a newer login for profile test replaced this one. Complete the newer login with its user code. Run the same command without --user-code and --confirmation-code to start a new login + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code NEWR-CODE --confirmation-code FTFA-QQKY +Profile test was successfully saved + +>>> print_requests.py --oneline //oidc/v1/token +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} + +=== Logins for different targets are independent + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile a --use-device-code + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile b --use-device-code + +>>> [CLI] auth login --host [DATABRICKS_URL] --use-device-code + +>>> cache_keys +[ + "a#device-code", + "b#device-code", + "[DATABRICKS_URL]", + "[DATABRICKS_URL]#device-code", + "test" +] + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile b --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Profile b was successfully saved + +>>> [CLI] auth login --host [DATABRICKS_URL] --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile a --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Profile a was successfully saved + +>>> cache_keys +[ + "a", + "b", + "[DATABRICKS_URL]", + "test" +] + +>>> print_requests.py --oneline //oidc/v1/token +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-4&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-5&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-3&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/script b/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/script new file mode 100644 index 00000000000..97ade6f94d2 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/concurrent/script @@ -0,0 +1,23 @@ +sethome "./home" + +# The real server issues a new user code for every login, while the fake server +# issues the same one. The script gives the second login its own user code. +title "A second login for the same profile replaces the first\n" +start_login LOG.first $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code +start_login LOG.second $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code +trace update_file.py home/.databricks/token-cache.json WDJBMJHT NEWRCODE +trace cache_keys +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code NEWR-CODE --confirmation-code FTFA-QQKY +trace print_requests.py --oneline //oidc/v1/token + +title "Logins for different targets are independent\n" +start_login LOG.a $CLI auth login --host $DATABRICKS_HOST --profile a --use-device-code +start_login LOG.b $CLI auth login --host $DATABRICKS_HOST --profile b --use-device-code +start_login LOG.host $CLI auth login --host $DATABRICKS_HOST --use-device-code +trace cache_keys +trace $CLI auth login --host $DATABRICKS_HOST --profile b --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace $CLI auth login --host $DATABRICKS_HOST --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace $CLI auth login --host $DATABRICKS_HOST --profile a --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace cache_keys +trace print_requests.py --oneline //oidc/v1/token diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/errors/home/.databrickscfg.tmpl b/acceptance/cmd/auth/login/device-code/non-interactive/errors/home/.databrickscfg.tmpl new file mode 100644 index 00000000000..3017709a305 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/errors/home/.databrickscfg.tmpl @@ -0,0 +1,4 @@ +[resources] +host = $DATABRICKS_HOST +resources = https://resource.test +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/errors/out.test.toml b/acceptance/cmd/auth/login/device-code/non-interactive/errors/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/errors/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/errors/output.txt b/acceptance/cmd/auth/login/device-code/non-interactive/errors/output.txt new file mode 100644 index 00000000000..a71936588a9 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/errors/output.txt @@ -0,0 +1,90 @@ + +=== Device code login is disabled by default + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code +Error: --use-device-code is experimental. Set DATABRICKS_CLI_DEVICE_CODE=true to enable it + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: --use-device-code is experimental. Set DATABRICKS_CLI_DEVICE_CODE=true to enable it + +=== Flags the non-interactive login cannot honor + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --configure-cluster +Error: --configure-cluster is not supported by the non-interactive device code login. Set cluster_id on the profile after the login + +=== Missing host + +>>> musterr [CLI] auth login --use-device-code +Error: --use-device-code requires a workspace host. Pass it as an argument or with --host + +>>> musterr [CLI] auth login --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: --use-device-code requires a workspace host. Pass it as an argument or with --host + +=== Incomplete flags of the second step + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --confirmation-code FTFA-QQKY +Error: --confirmation-code requires --user-code + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-MJHT +Error: --user-code requires --confirmation-code + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: --confirmation-code requires --use-device-code + +=== Account host + +>>> musterr [CLI] auth login --host https://accounts.cloud.databricks.test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Warn: [hostmetadata] failed to fetch host metadata for https://accounts.cloud.databricks.test, will skip for 1m0s +Error: --use-device-code is only supported for workspace hosts + +=== Profile scoped to resources + +>>> musterr [CLI] auth login --profile resources --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: --use-device-code cannot be used with profile resources because its login is scoped to resources. Log in without --use-device-code + +=== No pending login + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: no pending device code login for profile test. Run the same command without --user-code and --confirmation-code to start a new login + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: no pending device code login for host [DATABRICKS_URL]. Run the same command without --user-code and --confirmation-code to start a new login + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code + +=== Wrong confirmation code + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code AAAA-AAAA +Error: token exchange: Invalid confirmation code (error code: invalid_request). Check the confirmation code and approve the login if you haven't, then rerun this command. Run the same command without --user-code and --confirmation-code to start a new login if it still fails + +=== Rerun with the right confirmation code + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code wdjb-mjht --confirmation-code ftfa-qqky +Profile test was successfully saved + +=== Rerun after the login was completed + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: no pending device code login for profile test. Run the same command without --user-code and --confirmation-code to start a new login + +=== Device code unknown to the server + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code + +>>> update_file.py home/.databricks/token-cache.json device-code-2 unknown-device-code + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Error: token exchange: Invalid device code (error code: invalid_request). Run the same command without --user-code and --confirmation-code to start a new login + +>>> cache_keys +[ + "[DATABRICKS_URL]", + "test" +] + +>>> print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token +{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+all-apis"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=AAAAAAAA&device_code=device-code-1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} +{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+all-apis"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=unknown-device-code&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/errors/script b/acceptance/cmd/auth/login/device-code/non-interactive/errors/script new file mode 100644 index 00000000000..15a8010930f --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/errors/script @@ -0,0 +1,56 @@ +sethome "./home" + +title "Device code login is disabled by default\n" +( + unset DATABRICKS_CLI_DEVICE_CODE + trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code + trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +) + +title "Flags the non-interactive login cannot honor\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --configure-cluster + +# The non-interactive login cannot answer the profile picker or the host prompt. +title "Missing host\n" +trace musterr $CLI auth login --use-device-code +trace musterr $CLI auth login --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +title "Incomplete flags of the second step\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --confirmation-code FTFA-QQKY +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-MJHT +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +title "Account host\n" +trace musterr $CLI auth login --host https://accounts.cloud.databricks.test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +envsubst < "./home/.databrickscfg.tmpl" > "./home/.databrickscfg" + +title "Profile scoped to resources\n" +trace musterr $CLI auth login --profile resources --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +title "No pending login\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace musterr $CLI auth login --host $DATABRICKS_HOST --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +start_login LOG.wrong-code $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code + +title "Wrong confirmation code\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code AAAA-AAAA + +# A wrong confirmation code leaves the device code valid, so the pending +# login is kept. +title "Rerun with the right confirmation code\n" +trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code wdjb-mjht --confirmation-code ftfa-qqky + +title "Rerun after the login was completed\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +# The server discards the device code when the login expires. The pending +# login is then deleted. +title "Device code unknown to the server\n" +start_login LOG.expired $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code +trace update_file.py home/.databricks/token-cache.json device-code-2 unknown-device-code +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace cache_keys + +trace print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/flags/home/.databrickscfg.tmpl b/acceptance/cmd/auth/login/device-code/non-interactive/flags/home/.databrickscfg.tmpl new file mode 100644 index 00000000000..d8e7fb8ab03 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/flags/home/.databrickscfg.tmpl @@ -0,0 +1,5 @@ +[scoped] +host = $DATABRICKS_HOST +scopes = sql +client_id = databricks-cli +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/flags/out.databrickscfg b/acceptance/cmd/auth/login/device-code/non-interactive/flags/out.databrickscfg new file mode 100644 index 00000000000..18fafc1c622 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/flags/out.databrickscfg @@ -0,0 +1,15 @@ +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[scoped] +host = [DATABRICKS_URL] +scopes = sql +auth_type = databricks-cli +workspace_id = [WORKSPACE_ID] +client_id = databricks-cli + +[serverless] +host = [DATABRICKS_URL] +serverless_compute_id = auto +workspace_id = [WORKSPACE_ID] +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/flags/out.test.toml b/acceptance/cmd/auth/login/device-code/non-interactive/flags/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/flags/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/flags/output.txt b/acceptance/cmd/auth/login/device-code/non-interactive/flags/output.txt new file mode 100644 index 00000000000..a39e6a70953 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/flags/output.txt @@ -0,0 +1,36 @@ + +=== Scopes and client ID from the profile + +>>> [CLI] auth login --profile scoped --use-device-code +Using a browser on another device, visit: +[DATABRICKS_URL]/device/verification + +And enter the code: +WDJB-MJHT + +Once finished, complete the login by running the same command with +--user-code WDJB-MJHT and --confirmation-code set to the confirmation code +provided in your browser. + +>>> [CLI] auth login --profile scoped --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Profile scoped was successfully saved + +>>> print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token +{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+sql"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} + +=== Serverless compute is configured in the second step + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile serverless --use-device-code --configure-serverless +Using a browser on another device, visit: +[DATABRICKS_URL]/device/verification + +And enter the code: +WDJB-MJHT + +Once finished, complete the login by running the same command with +--user-code WDJB-MJHT and --confirmation-code set to the confirmation code +provided in your browser. + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile serverless --use-device-code --configure-serverless --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Profile serverless was successfully saved diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/flags/script b/acceptance/cmd/auth/login/device-code/non-interactive/flags/script new file mode 100644 index 00000000000..4824729081c --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/flags/script @@ -0,0 +1,16 @@ +sethome "./home" +envsubst < "./home/.databrickscfg.tmpl" > "./home/.databrickscfg" + +# The rerun resolves the login from the same flags and profile as the first +# step, so the token exchange uses the client ID of the profile. +title "Scopes and client ID from the profile\n" +trace $CLI auth login --profile scoped --use-device-code +trace $CLI auth login --profile scoped --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token + +# Only the second step saves the profile. +title "Serverless compute is configured in the second step\n" +trace $CLI auth login --host $DATABRICKS_HOST --profile serverless --use-device-code --configure-serverless +trace $CLI auth login --host $DATABRICKS_HOST --profile serverless --use-device-code --configure-serverless --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +mv "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.databrickscfg b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.databrickscfg new file mode 100644 index 00000000000..cf14685849c --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.databrickscfg @@ -0,0 +1,10 @@ +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[test] +host = [DATABRICKS_URL] +workspace_id = [WORKSPACE_ID] +auth_type = databricks-cli + +[__settings__] +default_profile = test diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.test.toml b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/output.txt b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/output.txt new file mode 100644 index 00000000000..5a70dc2fd0c --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/output.txt @@ -0,0 +1,93 @@ + +=== Start the login without a profile + +>>> [CLI] auth login --host [DATABRICKS_URL] --use-device-code +Using a browser on another device, visit: +[DATABRICKS_URL]/device/verification + +And enter the code: +WDJB-MJHT + +Once finished, complete the login by running the same command with +--user-code WDJB-MJHT and --confirmation-code set to the confirmation code +provided in your browser. + +=== The first step stores the device code and saves no profile + +>>> find.py home +home/.databricks/token-cache.json + +>>> cache_keys +[ + "[DATABRICKS_URL]#device-code" +] + +=== Confirm the login + +>>> [CLI] auth login --host [DATABRICKS_URL] --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + +>>> [CLI] auth token --host [DATABRICKS_URL] +json.access_token = "oauth-token"; + +>>> cache_keys +[ + "[DATABRICKS_URL]" +] + +=== Start the login with a profile + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code +Using a browser on another device, visit: +[DATABRICKS_URL]/device/verification + +And enter the code: +WDJB-MJHT + +Once finished, complete the login by running the same command with +--user-code WDJB-MJHT and --confirmation-code set to the confirmation code +provided in your browser. + +=== Confirm the login + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Profile test was successfully saved + +>>> [CLI] auth token --profile test +json.access_token = "oauth-token"; + +>>> [CLI] auth profiles +Name Host Valid +test (Default) [DATABRICKS_URL] YES + +=== Start the login again with the host from the saved profile + +>>> [CLI] auth login --profile test --use-device-code +Using a browser on another device, visit: +[DATABRICKS_URL]/device/verification + +And enter the code: +WDJB-MJHT + +Once finished, complete the login by running the same command with +--user-code WDJB-MJHT and --confirmation-code set to the confirmation code +provided in your browser. + +>>> [CLI] auth token --profile test +json.access_token = "oauth-token"; + +>>> [CLI] auth login --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Profile test was successfully saved + +>>> cache_keys +[ + "[DATABRICKS_URL]", + "test" +] + +>>> print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token +{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+all-apis"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} +{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+all-apis"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} +{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+all-apis"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-3&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/script b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/script new file mode 100644 index 00000000000..2f00d282b27 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/script @@ -0,0 +1,35 @@ +sethome "./home" + +# Acceptance tests run the CLI without a terminal on stdin, so device code +# login runs non-interactively. +title "Start the login without a profile\n" +trace $CLI auth login --host $DATABRICKS_HOST --use-device-code + +title "The first step stores the device code and saves no profile\n" +trace find.py home +trace cache_keys + +title "Confirm the login\n" +trace $CLI auth login --host $DATABRICKS_HOST --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace $CLI auth token --host $DATABRICKS_HOST | gron.py | grep access_token +trace cache_keys + +title "Start the login with a profile\n" +trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code + +title "Confirm the login\n" +trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace $CLI auth token --profile test | gron.py | grep access_token +trace $CLI auth profiles + +# The pending login is stored next to the token, so the token keeps working +# until the login is confirmed. +title "Start the login again with the host from the saved profile\n" +trace $CLI auth login --profile test --use-device-code +trace $CLI auth token --profile test | gron.py | grep access_token +trace $CLI auth login --profile test --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +trace cache_keys +trace print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token + +# Track the .databrickscfg file that was created to surface changes. +mv "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/script.prepare b/acceptance/cmd/auth/login/device-code/non-interactive/script.prepare new file mode 100644 index 00000000000..447c920c748 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/script.prepare @@ -0,0 +1,13 @@ +# cache_keys lists the keys in the plaintext token cache. +cache_keys() { + jq -S '.tokens | keys' "./home/.databricks/token-cache.json" +} + +# start_login traces the first step in "$@" and saves its output to file $1, +# for steps whose output was already shown. +start_login() { + local log="$1" + shift + >&2 printf "\n>>> %s\n" "$*" + "$@" &> "$log" +} diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/test.toml b/acceptance/cmd/auth/login/device-code/non-interactive/test.toml new file mode 100644 index 00000000000..f493574d1e4 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/non-interactive/test.toml @@ -0,0 +1,3 @@ +# A dedicated server issues device codes in a known order, so that the scripts +# can confirm them. +RecordRequests = true diff --git a/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt b/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt index 7875f49ed6b..5d327ce2db2 100644 --- a/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt +++ b/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt @@ -2,7 +2,7 @@ === No host >>> musterr [CLI] auth login --profile new-profile --use-device-code -Error: the command is being run in a non-interactive environment, please specify a host using --host +Error: --use-device-code requires a workspace host. Pass it as an argument or with --host === Account host diff --git a/acceptance/cmd/auth/login/device-code/unsupported-host/script b/acceptance/cmd/auth/login/device-code/unsupported-host/script index d744aec1709..2d0a8949c8e 100644 --- a/acceptance/cmd/auth/login/device-code/unsupported-host/script +++ b/acceptance/cmd/auth/login/device-code/unsupported-host/script @@ -4,7 +4,7 @@ title "No host\n" # The profile does not exist yet and no host is given. auth login does not read # DATABRICKS_HOST, so the usual login would fall back to the browser-based # discovery flow. Device code login skips it and asks for the host instead, -# which fails without a terminal. +# which the non-interactive login cannot do. trace musterr $CLI auth login --profile new-profile --use-device-code envsubst < "./home/.databrickscfg.tmpl" > "./home/.databrickscfg" diff --git a/cmd/auth/README.md b/cmd/auth/README.md index 11d43bc14c4..aa453878d3b 100644 --- a/cmd/auth/README.md +++ b/cmd/auth/README.md @@ -104,3 +104,34 @@ Notes on the server behavior the CLI relies on: - The refresh token is single-use (rotating) and has an absolute lifetime of 24 hours by default. After that, `databricks auth token` fails with a refresh error and the user needs to log in again. + +## Non-interactive login + +Device code login reads the confirmation code from stdin. When stdin is not a +terminal (`cmdio.IsInputTTY`), for example when an AI agent runs one-off shell +commands, nobody can type the code in, and `auth login --use-device-code` runs +in two steps instead: + +1. `auth login --use-device-code` requests the device authorization, stores + the device code as a pending login in the token store, and prints the + verification URI and the user code. It skips the prompts and saves no + profile. +2. The same command with the hidden `--user-code` and `--confirmation-code` + flags exchanges the pending device code instead of requesting a new one, + and the rest of the login runs as usual. + +The pending login is a token store entry with `PendingLogin` set instead of a +token. Its key is the login's cache key (the profile name, or the host without +a profile) with a `#device-code` suffix, so the target's current token keeps +working until the login completes. The device code never leaves the machine; +only the user code and the confirmation code are printed or passed on the +command line, and the confirmation code is useless without the device code. +This keeps the confirmation code's protection against phishing: a code read +from, e.g., an agent's transcript cannot complete the login on another machine. + +Each target has one pending login, and a new login for it replaces the old +one. The second step checks the user code, so a replaced login fails with a +clear error without sending its confirmation code. A wrong confirmation code +keeps the pending login for a rerun. A successful exchange, or a device code +the server no longer knows, deletes it. The CLI does not expire pending logins +itself: the server does. diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 5c9f322d53d..44c93be0aa5 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -155,6 +155,8 @@ a new profile is created. var clientID string var resources []string var useDeviceCode bool + var userCode string + var confirmationCode string cmd.Flags().DurationVar(&loginTimeout, "timeout", defaultTimeout, "Timeout for completing login challenge in the browser") cmd.Flags().BoolVar(&configureCluster, "configure-cluster", false, @@ -173,6 +175,15 @@ a new profile is created. "Log in with a device code, using a browser on another device (experimental)") // Hidden while device code login is experimental. cmd.Flags().MarkHidden("use-device-code") + // The non-interactive device code login tells the user to rerun the + // command with these flags, which completes the login. Hidden because only + // that rerun uses them. + cmd.Flags().StringVar(&userCode, "user-code", "", + "User code of the device code login to complete") + cmd.Flags().StringVar(&confirmationCode, "confirmation-code", "", + "Confirmation code that completes a device code login") + cmd.Flags().MarkHidden("user-code") + cmd.Flags().MarkHidden("confirmation-code") cmd.PreRunE = profileHostConflictCheck @@ -205,10 +216,24 @@ a new profile is created. } } + completeLogin := cmd.Flags().Changed("confirmation-code") + if err := validateDeviceCodeCompletionFlags(useDeviceCode, cmd.Flags().Changed("user-code"), completeLogin); err != nil { + return err + } + // Device code login reads the confirmation code from stdin. When stdin + // is not a terminal, e.g. when an AI agent runs the login as a one-off + // command, nobody can type the code in. The non-interactive login then + // stores the device code, and a rerun of the command with --user-code + // and --confirmation-code completes the login. Neither step prompts. + var nonInteractive bool if useDeviceCode { if err := validateDeviceCodeLogin(ctx, cmd); err != nil { return err } + nonInteractive = completeLogin || !cmdio.IsInputTTY(ctx) + if nonInteractive && configureCluster { + return errDeviceCodeNonInteractiveCluster + } } // Resolve the cache before the browser step so an unavailable @@ -228,7 +253,7 @@ a new profile is created. // (Create a new profile) and a manual host URL. Device code login skips // discovery, so its "Create a new profile" asks for the host after the // profile name, unless a profile with that name already has a host. - if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) { + if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) && !nonInteractive { matchProfiles := profile.MatchAllProfiles if useDeviceCode { matchProfiles = matchDeviceCodeProfiles @@ -286,8 +311,9 @@ a new profile is created. runHostDiscovery(ctx, authArguments) } - // If the user has not specified a profile name, prompt for one. - if profileName == "" { + // If the user has not specified a profile name, prompt for one. Without + // a profile, the non-interactive login stores the token under the host. + if profileName == "" && !nonInteractive { var err error profileName = getProfileName(authArguments) if profileName == "" { @@ -316,6 +342,11 @@ a new profile is created. } } + // The non-interactive login cannot answer the host prompt of setHost. + if nonInteractive && shouldUseDiscovery(authArguments.Host, args, existingProfile) { + return errDeviceCodeNeedsHost + } + // If no host is available from any source, use the discovery flow // via login.databricks.com. Discovery opens a browser on this machine, // and device code login is meant for machines without one, so it skips @@ -399,10 +430,22 @@ a new profile is created. } defer persistentAuth.Close() + nonInteractiveLogin := nonInteractiveDeviceCodeLogin{ + persistentAuth: persistentAuth, + tokenStore: tokenStore, + key: pendingLoginKey(oauthArgument.GetCacheKey()), + profile: profileName, + host: authArguments.Host, + } var token *oauth2.Token - if useDeviceCode { + switch { + case completeLogin: + token, err = nonInteractiveLogin.complete(ctx, userCode, confirmationCode) + case nonInteractive: + return nonInteractiveLogin.start(ctx) + case useDeviceCode: token, err = persistentAuth.DeviceCodeChallenge(ctx, promptForConfirmationCode) - } else { + default: token, err = persistentAuth.Challenge() } if err != nil { diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index 84ca1b03762..c76de15d3cd 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -6,13 +6,25 @@ import ( "fmt" "strings" + "github.com/databricks/cli/libs/auth/storage" "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/env" + "github.com/databricks/cli/libs/log" "github.com/spf13/cobra" + "golang.org/x/oauth2" ) +// pendingLoginKeySuffix marks the token store entry that holds a +// non-interactive device code login until it completes. The entry sits next to +// the login's token, so that a token that still works is kept until then. +const pendingLoginKeySuffix = "#device-code" + +// startNewLoginHint ends the errors of a non-interactive device code login +// that cannot be completed. +const startNewLoginHint = "Run the same command without --user-code and --confirmation-code to start a new login" + // deviceCodeIncompatibleFlags lists flags that device code login cannot honor. var deviceCodeIncompatibleFlags = []string{ // Resource indicators are sent on the browser authorization request, @@ -25,6 +37,8 @@ var deviceCodeIncompatibleFlags = []string{ var ( errDeviceCodeNeedsHost = errors.New("--use-device-code requires a workspace host. Pass it as an argument or with --host") errDeviceCodeWorkspaceOnly = errors.New("--use-device-code is only supported for workspace hosts") + // The cluster picker is interactive. + errDeviceCodeNonInteractiveCluster = errors.New("--configure-cluster is not supported by the non-interactive device code login. Set cluster_id on the profile after the login") ) // isDeviceCodeSupportedForHost reports whether host is a workspace host. A @@ -57,6 +71,120 @@ func validateDeviceCodeLogin(ctx context.Context, cmd *cobra.Command) error { return nil } +// validateDeviceCodeCompletionFlags returns an error if the flags that complete +// a non-interactive device code login are not set together. +func validateDeviceCodeCompletionFlags(useDeviceCode, hasUserCode, hasConfirmationCode bool) error { + switch { + case hasConfirmationCode && !hasUserCode: + return errors.New("--confirmation-code requires --user-code") + case hasUserCode && !hasConfirmationCode: + return errors.New("--user-code requires --confirmation-code") + case hasConfirmationCode && !useDeviceCode: + return errors.New("--confirmation-code requires --use-device-code") + } + return nil +} + +// nonInteractiveDeviceCodeLogin runs the two steps of a device code login +// that cannot read the confirmation code from stdin. The first step stores the +// device code as a pending login under key, and the second step exchanges it, +// so that the device code never leaves the machine. +type nonInteractiveDeviceCodeLogin struct { + persistentAuth *u2m.PersistentAuth + tokenStore storage.Store + key string + profile string + host string +} + +// pendingLoginKey returns the token store key of the pending login for the +// login with cacheKey. +func pendingLoginKey(cacheKey string) string { + return cacheKey + pendingLoginKeySuffix +} + +// target describes the login in errors. +func (l nonInteractiveDeviceCodeLogin) target() string { + if l.profile != "" { + return "profile " + l.profile + } + return "host " + l.host +} + +// start requests a device authorization, stores it as the pending login, and +// prints how to complete the login. A pending login under the same key is +// replaced. +func (l nonInteractiveDeviceCodeLogin) start(ctx context.Context) error { + da, err := l.persistentAuth.RequestDeviceAuthorization(ctx) + if err != nil { + return err + } + err = l.tokenStore.Put(l.key, storage.Entry{PendingLogin: &storage.PendingLogin{ + DeviceCode: da.DeviceCode, + UserCode: da.UserCode, + }}) + if err != nil { + return fmt.Errorf("store pending login: %w", err) + } + cmdio.LogString(ctx, strings.Join([]string{ + "Using a browser on another device, visit:", + da.VerificationURI, + "", + "And enter the code:", + formatCode(da.UserCode), + "", + "Once finished, complete the login by running the same command with", + "--user-code " + formatCode(da.UserCode) + " and --confirmation-code set to the confirmation code", + "provided in your browser.", + }, "\n")) + return nil +} + +// complete exchanges the device code of the pending login started with +// userCode together with confirmationCode for a token. +func (l nonInteractiveDeviceCodeLogin) complete(ctx context.Context, userCode, confirmationCode string) (*oauth2.Token, error) { + e, err := l.tokenStore.Lookup(l.key) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return nil, fmt.Errorf("read pending login: %w", err) + } + // The key can also hold the token of a profile that happens to be named + // like a pending login key. + pending := e.PendingLogin + if pending == nil { + return nil, fmt.Errorf("no pending device code login for %s. %s", l.target(), startNewLoginHint) + } + // Each profile or host has one pending login, so a newer login replaces + // it. Checking the user code here avoids sending the confirmation code + // with the wrong device code, which the server would reject as a wrong + // confirmation code. + if !sameCode(pending.UserCode, userCode) { + return nil, fmt.Errorf("a newer login for %s replaced this one. Complete the newer login with its user code. %s", l.target(), startNewLoginHint) + } + + token, err := l.persistentAuth.ExchangeDeviceCode(ctx, pending.DeviceCode, confirmationCode) + // A wrong confirmation code leaves the device code valid, so the pending + // login is kept for a rerun. Otherwise the server no longer accepts the + // device code. + if err == nil || errors.Is(err, u2m.ErrInvalidDeviceCode) { + if err := l.tokenStore.Delete(l.key); err != nil { + log.Debugf(ctx, "Failed to delete the pending device code login: %v", err) + } + } + switch { + case errors.Is(err, u2m.ErrInvalidConfirmationCode): + return nil, fmt.Errorf("%w. Check the confirmation code and approve the login if you haven't, then rerun this command. %s if it still fails", err, startNewLoginHint) + case errors.Is(err, u2m.ErrInvalidDeviceCode): + return nil, fmt.Errorf("%w. %s", err, startNewLoginHint) + } + return token, err +} + +// sameCode reports whether two device codes match when compared the way the +// user enters them. +func sameCode(a, b string) bool { + return u2m.NormalizeCode(a) == u2m.NormalizeCode(b) +} + // promptForConfirmationCode shows the user where to approve the login and // reads the confirmation code from stdin. If the server rejected the previous // code, it shows the server's error and the number of attempts left instead diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index a95f5a7d2da..db6afed95ea 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -215,9 +215,9 @@ func TestIsDeviceCodeSupportedForHost(t *testing.T) { } } -// startDeviceCodeLogin runs "auth login --use-device-code" with args in a -// terminal emulator, using config as the contents of .databrickscfg. -func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest.Term[struct{}] { +// setUpDeviceCodeLogin isolates the environment of a device code login, using +// config as the contents of .databrickscfg. +func setUpDeviceCodeLogin(t *testing.T, config string) { testutil.CleanupEnvironment(t) home := t.TempDir() t.Setenv("HOME", home) @@ -225,17 +225,27 @@ func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, config)) t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") t.Setenv(deviceCodeEnvVar, "true") +} + +// runDeviceCodeLogin runs "auth login --use-device-code" with args. +func runDeviceCodeLogin(ctx context.Context, args ...string) error { + authArguments := &auth.AuthArguments{} + cmd := newLoginCommand(authArguments) + cmd.Flags().StringVar(&authArguments.Host, "host", "", "") + cmd.Flags().String("profile", "", "") + cmd.SetContext(ctx) + cmd.SetArgs(append([]string{"--use-device-code"}, args...)) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + return cmd.Execute() +} +// startDeviceCodeLogin runs "auth login --use-device-code" with args in a +// terminal emulator, using config as the contents of .databrickscfg. +func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest.Term[struct{}] { + setUpDeviceCodeLogin(t, config) return termtest.Run(t, func(ctx context.Context) (struct{}, error) { - authArguments := &auth.AuthArguments{} - cmd := newLoginCommand(authArguments) - cmd.Flags().StringVar(&authArguments.Host, "host", "", "") - cmd.Flags().String("profile", "", "") - cmd.SetContext(ctx) - cmd.SetArgs(append([]string{"--use-device-code"}, args...)) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - return struct{}{}, cmd.Execute() + return struct{}{}, runDeviceCodeLogin(ctx, args...) }) } @@ -315,6 +325,39 @@ resources = https://workspace.test/ai-gateway/mcp-services/system.ai.github assert.EqualError(t, err, "^C") } +func TestValidateDeviceCodeCompletionFlags(t *testing.T) { + tests := []struct { + name string + useDeviceCode bool + hasUserCode bool + hasConfirmationCode bool + wantErr string + }{ + {name: "neither", useDeviceCode: true}, + {name: "both", useDeviceCode: true, hasUserCode: true, hasConfirmationCode: true}, + {name: "confirmation code only", useDeviceCode: true, hasConfirmationCode: true, wantErr: "--confirmation-code requires --user-code"}, + {name: "user code only", useDeviceCode: true, hasUserCode: true, wantErr: "--user-code requires --confirmation-code"}, + {name: "without device code", hasUserCode: true, hasConfirmationCode: true, wantErr: "--confirmation-code requires --use-device-code"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDeviceCodeCompletionFlags(tt.useDeviceCode, tt.hasUserCode, tt.hasConfirmationCode) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSameCode(t *testing.T) { + assert.True(t, sameCode("WDJBMJHT", "WDJB-MJHT")) + assert.True(t, sameCode("WDJBMJHT", "wdjbmjht")) + assert.True(t, sameCode("WDJBMJHT", " WDJB-MJHT\n")) + assert.False(t, sameCode("WDJBMJHT", "WDJB-MJHA")) +} + // newDeviceCodeServer returns a fake workspace with device code login. func newDeviceCodeServer(t *testing.T) *testserver.Server { server := testserver.New(t) @@ -397,3 +440,38 @@ func TestLoginDeviceCodeRequestsScopes(t *testing.T) { assert.Equal(t, strconv.Itoa(testserver.TestWorkspaceID), p.WorkspaceID) assert.Equal(t, "sql", p.Scopes) } + +// An agent without a terminal can start the login, and the user can complete +// it in a terminal. The second step does not prompt for a profile, so it finds +// the pending login under the host, like the first step did. +func TestLoginDeviceCodeCompletesInTerminal(t *testing.T) { + server := newDeviceCodeServer(t) + setUpDeviceCodeLogin(t, "") + ctx := cmdio.InContext(t.Context(), cmdio.NewIO(t.Context(), flags.OutputText, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "", "")) + require.NoError(t, runDeviceCodeLogin(ctx, "--host", server.URL)) + + tm := termtest.Run(t, func(ctx context.Context) (struct{}, error) { + return struct{}{}, runDeviceCodeLogin(ctx, "--host", server.URL, "--user-code", "WDJB-MJHT", "--confirmation-code", "FTFA-QQKY") + }) + _, err := tm.Result() + require.NoError(t, err) + + raw, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".databricks", "token-cache.json")) + require.NoError(t, err) + var cache struct { + Tokens map[string]json.RawMessage `json:"tokens"` + } + require.NoError(t, json.Unmarshal(raw, &cache)) + assert.Contains(t, cache.Tokens, server.URL) + assert.NotContains(t, cache.Tokens, pendingLoginKey(server.URL)) + + profiles, err := profile.DefaultProfiler.LoadProfiles(t.Context(), profile.MatchAllProfiles) + require.NoError(t, err) + assert.Empty(t, profiles) +} + +func TestLoginDeviceCodeCompletionWithoutHostSkipsPicker(t *testing.T) { + tm := startDeviceCodeLogin(t, "", "--user-code", "WDJB-MJHT", "--confirmation-code", "FTFA-QQKY") + _, err := tm.Result() + assert.ErrorIs(t, err, errDeviceCodeNeedsHost) +} diff --git a/docs/device-code-login.md b/docs/device-code-login.md index 08f1fcab358..7f53a1ea5e2 100644 --- a/docs/device-code-login.md +++ b/docs/device-code-login.md @@ -46,11 +46,59 @@ Profile my-profile was successfully saved 4. The browser shows a confirmation code. Type it into the CLI. Codes are not case-sensitive, and the dash is optional. If the code is rejected, the CLI asks for it again and shows how many attempts are left, up to 3 attempts in - total. Never share the confirmation code with anyone. + total. Only give the confirmation code to the CLI that started the login, + or to the AI agent that runs it (see + [Log in without a terminal](#log-in-without-a-terminal)). Never share it + with anyone else. The CLI saves the profile and the token the same way as the browser-based `databricks auth login`, so other commands use it via `--profile my-profile`. +## Log in without a terminal + +When stdin is not a terminal, nobody can type the confirmation code into the +running CLI. This is the case when a script or an AI agent such as Claude Code +runs the login as a one-off command. The login then runs non-interactively, in +two steps: + +```console +$ databricks auth login --host https:// --profile my-profile --use-device-code < /dev/null +Using a browser on another device, visit: +https:///device/verification + +And enter the code: +WDJB-MJHT + +Once finished, complete the login by running the same command with +--user-code WDJB-MJHT and --confirmation-code set to the confirmation code +provided in your browser. +$ databricks auth login --host https:// --profile my-profile --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Profile my-profile was successfully saved +``` + +1. The first command prints the URL and the code, and keeps the pending login + in the CLI's token storage. It does not change the profile, so commands + that use it keep working until the login completes. +2. Approve the login in the browser as described above. +3. Run the same command with the hidden `--user-code` flag set to the code + from step 1 and the hidden `--confirmation-code` flag set to the + confirmation code. It saves the profile and the token. Without + `--profile`, it only stores the token for the host. + +The pending login stays on your machine, so a confirmation code that ends up +somewhere else, such as an agent's conversation, cannot complete the login on +another machine. + +If the confirmation code is rejected, check it and run the command again. If +the login expired, start a new one. + +Each profile, or host without a profile, has one pending login. Starting a +second login for it replaces the first, so complete one login before starting +another. + +`--configure-cluster` is not supported by the non-interactive login. Set +`cluster_id` on the profile after the login. + ## Session lifetime The token is renewed automatically for up to 24 hours. After that, commands @@ -68,5 +116,12 @@ fail with a token refresh error and you need to run the login again. | `token exchange: Invalid device code` | The login expired. A confirmation code entered after the login expired is first rejected as `Invalid confirmation code`, and the workspace then discards the login. | Run the login again to get a new code. | | `token exchange: Device grant rate limit exceeded` | Too many confirmation codes were sent for this login. | Run the login again to get a new code. | | `unauthorized_client` or `invalid_client` | The OAuth client is not allowed to use device code login in this workspace. | Use the default `databricks-cli` client, or ask your admin to enable the custom client. | -| `reading confirmation code: EOF` | stdin was closed before a code was entered. | Keep stdin open until you write the confirmation code. | +| `--configure-cluster is not supported by the non-interactive device code login` | The cluster picker needs a terminal. | Log in first, then set `cluster_id` on the profile. | +| `token exchange: Invalid confirmation code (error code: invalid_request). Check the confirmation code ...` | The confirmation code was mistyped, or the login was not approved yet. | Check the code and approve the login, then run the command again. If it still fails, start a new login. | +| `token exchange: Invalid device code (error code: invalid_request). Run the same command without --user-code and --confirmation-code ...` | The login expired. | Start a new login. | +| `a newer login for profile ... replaced this one` | A second login was started for the same profile or host. | Approve the newest code, and complete the newest login with its user code. | +| `no pending device code login for profile ...` | The login was already completed, or no login was started for this profile or host. | Start a new login. | +| `--confirmation-code requires --user-code` | `--user-code` is missing from the command. | Add `--user-code` with the code from the first step. | +| `--confirmation-code requires --use-device-code` | `--use-device-code` is missing from the command. | Run the same command as the first step, with `--user-code` and `--confirmation-code`. | +| `reading confirmation code: EOF` | The input was closed, e.g. with Ctrl+D, before a code was entered. | Run the login again. | | `reading confirmation code: context deadline exceeded` | No confirmation code was entered before the login timed out (1 hour by default). | Run the login again. Use `--timeout` to change the limit. |