From f293777c9ab65d720205f881884eb6a62ec384ec Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Mon, 26 Oct 2026 10:55:53 +0000 Subject: [PATCH 1/5] Split device code login into authorization and token exchange steps Export RequestDeviceAuthorization and ExchangeDeviceCode so a login can be completed by a separate command, and rebuild DeviceCodeChallenge on top of them. Rejected codes now match ErrInvalidConfirmationCode or ErrInvalidDeviceCode, so callers no longer compare the server's description. Export NormalizeCode so that callers can compare codes the way the user enters them. Co-authored-by: Isaac --- libs/auth/u2m/device_code.go | 126 ++++++++++++++++++++++++------ libs/auth/u2m/device_code_test.go | 85 +++++++++++++++++++- 2 files changed, 188 insertions(+), 23 deletions(-) diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 51f77f8e189..a5e5a9c82ed 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -32,14 +32,27 @@ const invalidDeviceCodeDescription = "Invalid device code" // a device authorization endpoint in its OIDC discovery document. var ErrDeviceCodeNotSupported = errors.New("device code login is not enabled for this workspace") +// ErrInvalidConfirmationCode matches the token exchange error for a +// confirmation code the server rejects. The device code stays valid, so the +// exchange can be retried with the right code. +var ErrInvalidConfirmationCode = errors.New("invalid confirmation code") + +// ErrInvalidDeviceCode matches the token exchange error for a device code the +// server does not know, e.g. one it discarded after the login expired. The +// login has to be started again. +var ErrInvalidDeviceCode = errors.New("invalid device code") + // DeviceAuthorization holds what the user needs to approve a device code -// login from another device. +// login from another device, and the device code to exchange afterwards. type DeviceAuthorization struct { // UserCode is the code the user enters at VerificationURI. UserCode string // VerificationURI is the page where the user approves the login. VerificationURI string + + // DeviceCode identifies the login in the token exchange. + DeviceCode string } // maxConfirmationAttempts is how many confirmation codes the user can enter @@ -90,44 +103,113 @@ func (a *PersistentAuth) DeviceCodeChallenge(ctx context.Context, confirm Confir da, err := a.requestDeviceAuthorization(ctx, cfg) if err != nil { - return nil, fmt.Errorf("device authorization: %w", oauthError(err)) + return nil, err } - authorization := DeviceAuthorization{ - UserCode: da.UserCode, - VerificationURI: da.VerificationURI, - } var lastErr error for attempt := range maxConfirmationAttempts { - code, err := confirm(ctx, authorization, lastErr, maxConfirmationAttempts-attempt) + code, err := confirm(ctx, *da, lastErr, maxConfirmationAttempts-attempt) if err != nil { return nil, err } - t, err := a.exchangeDeviceCode(ctx, cfg, da.DeviceCode, normalizeCode(code)) + t, err := a.exchangeConfirmationCode(ctx, cfg, da.DeviceCode, code) if err == nil { return t, nil } - lastErr = oauthError(err) - // The server answers a wrong confirmation code with HTTP 400 and - // invalid_request. Any other error cannot be fixed by entering the - // code again, including invalid_request with HTTP 429 when the server - // rate limits the token requests for this device code. An unknown - // device code also gets HTTP 400 and invalid_request, and only the - // description tells it apart. If the description changes, the user is - // asked for the code again until the attempts run out. - status, errCode, description := oauthErrorResponse(err) - if status != http.StatusBadRequest || errCode != "invalid_request" || description == invalidDeviceCodeDescription { + lastErr = err + if !errors.Is(err, ErrInvalidConfirmationCode) { return nil, fmt.Errorf("token exchange: %w", lastErr) } } return nil, fmt.Errorf("%w. Start login from scratch", lastErr) } -// requestDeviceAuthorization makes the RFC 8628 device authorization request. +// RequestDeviceAuthorization starts a device code login by making the RFC +// 8628 device authorization request. The caller shows the user code and the +// verification URI to the user and later passes the device code to +// ExchangeDeviceCode. Only workspace hosts are supported. +func (a *PersistentAuth) RequestDeviceAuthorization(ctx context.Context) (*DeviceAuthorization, error) { + cfg, err := a.deviceCodeConfig() + if err != nil { + return nil, err + } + return a.requestDeviceAuthorization(ctx, cfg) +} + +// ExchangeDeviceCode completes a device code login started by +// RequestDeviceAuthorization with a single token request, without retries. +// The confirmation code is accepted the way the browser displays it. An +// error matches ErrInvalidConfirmationCode or ErrInvalidDeviceCode when the +// server rejects one of the codes. The caller is responsible for storing the +// returned token. +func (a *PersistentAuth) ExchangeDeviceCode(ctx context.Context, deviceCode, confirmationCode string) (*oauth2.Token, error) { + cfg, err := a.deviceCodeConfig() + if err != nil { + return nil, err + } + return a.exchangeConfirmationCode(ctx, cfg, deviceCode, confirmationCode) +} + +// requestDeviceAuthorization makes the RFC 8628 device authorization request +// and returns what the user needs to approve the login. +func (a *PersistentAuth) requestDeviceAuthorization(ctx context.Context, cfg *oauth2.Config) (*DeviceAuthorization, error) { + da, err := a.postDeviceAuthorization(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("device authorization: %w", oauthError(err)) + } + return &DeviceAuthorization{ + UserCode: da.UserCode, + VerificationURI: da.VerificationURI, + DeviceCode: da.DeviceCode, + }, nil +} + +// exchangeConfirmationCode exchanges the device code and the confirmation +// code for a token, and marks the errors for a rejected code. +func (a *PersistentAuth) exchangeConfirmationCode(ctx context.Context, cfg *oauth2.Config, deviceCode, confirmationCode string) (*oauth2.Token, error) { + t, err := a.exchangeDeviceCode(ctx, cfg, deviceCode, NormalizeCode(confirmationCode)) + if err == nil { + return t, nil + } + // The server answers a wrong confirmation code with HTTP 400 and + // invalid_request. Any other error cannot be fixed by entering the + // code again, including invalid_request with HTTP 429 when the server + // rate limits the token requests for this device code. An unknown + // device code also gets HTTP 400 and invalid_request, and only the + // description tells it apart. If the description changes, the error is + // reported as a wrong confirmation code. + status, errCode, description := oauthErrorResponse(err) + err = oauthError(err) + switch { + case status != http.StatusBadRequest || errCode != "invalid_request": + return nil, err + case description == invalidDeviceCodeDescription: + return nil, &codeRejectedError{err: err, sentinel: ErrInvalidDeviceCode} + default: + return nil, &codeRejectedError{err: err, sentinel: ErrInvalidConfirmationCode} + } +} + +// codeRejectedError shows the server's error for a rejected code and matches +// sentinel with errors.Is. +type codeRejectedError struct { + err error + sentinel error +} + +func (e *codeRejectedError) Error() string { + return e.err.Error() +} + +func (e *codeRejectedError) Unwrap() []error { + return []error{e.err, e.sentinel} +} + +// postDeviceAuthorization makes the RFC 8628 device authorization request. // Unlike oauth2.Config.DeviceAuth, it sends the request with ctx, so that the // login timeout and cancellation stop a stalled request. -func (a *PersistentAuth) requestDeviceAuthorization(ctx context.Context, cfg *oauth2.Config) (*oauth2.DeviceAuthResponse, error) { +func (a *PersistentAuth) postDeviceAuthorization(ctx context.Context, cfg *oauth2.Config) (*oauth2.DeviceAuthResponse, error) { body, err := a.postForm(ctx, cfg.Endpoint.DeviceAuthURL, url.Values{ "client_id": {cfg.ClientID}, "scope": {strings.Join(cfg.Scopes, " ")}, @@ -235,10 +317,10 @@ func (a *PersistentAuth) postForm(ctx context.Context, endpoint string, form url return body, nil } -// normalizeCode lets users enter a code the way it is displayed. Databricks +// NormalizeCode lets users enter a code the way it is displayed. Databricks // codes consist of 8 uppercase alphanumeric characters and are compared // case-sensitively, but are often shown as two groups of four separated by a // dash (e.g. FTFA-QQKY). -func normalizeCode(code string) string { +func NormalizeCode(code string) string { return strings.NewReplacer("-", "", " ", "").Replace(strings.ToUpper(strings.TrimSpace(code))) } diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index f976ef5555d..c6d410da4df 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -133,6 +133,7 @@ func TestDeviceCodeChallenge(t *testing.T) { assert.Equal(t, DeviceAuthorization{ UserCode: testUserCode, VerificationURI: "https://workspace.test/device/verification", + DeviceCode: testDeviceCode, }, got) assert.Equal(t, "access-token", tok.AccessToken) assert.Equal(t, "refresh-token", tok.RefreshToken) @@ -401,6 +402,88 @@ func TestDeviceCodeChallenge_AccountHost(t *testing.T) { assert.EqualError(t, err, "device code login is only supported for workspace hosts") } +func TestRequestDeviceAuthorization(t *testing.T) { + f := &fakeDeviceServer{expiresIn: 600} + p := newDeviceCodeAuth(t, f, true) + + da, err := p.RequestDeviceAuthorization(t.Context()) + require.NoError(t, err) + assert.Equal(t, &DeviceAuthorization{ + UserCode: testUserCode, + VerificationURI: "https://workspace.test/device/verification", + DeviceCode: testDeviceCode, + }, da) + assert.Equal(t, int32(0), f.tokenRequests.Load()) +} + +func TestRequestDeviceAuthorization_Errors(t *testing.T) { + _, err := newDeviceCodeAuth(t, &fakeDeviceServer{}, false).RequestDeviceAuthorization(t.Context()) + assert.ErrorIs(t, err, ErrDeviceCodeNotSupported) + + _, err = newDeviceCodeAuth(t, &fakeDeviceServer{ + deviceAuthStatus: http.StatusBadRequest, + deviceAuthBody: `{"error": "unauthorized_client", "error_description": "Client is not allowed to use device code"}`, + }, true).RequestDeviceAuthorization(t.Context()) + assert.EqualError(t, err, "device authorization: Client is not allowed to use device code (error code: unauthorized_client)") +} + +func TestExchangeDeviceCode(t *testing.T) { + f := &fakeDeviceServer{} + p := newDeviceCodeAuth(t, f, true) + + tok, err := p.ExchangeDeviceCode(t.Context(), testDeviceCode, " ftfa-qqky\n") + require.NoError(t, err) + assert.Equal(t, "access-token", tok.AccessToken) + assert.Equal(t, "refresh-token", tok.RefreshToken) + assert.Equal(t, int32(1), f.tokenRequests.Load()) +} + +func TestExchangeDeviceCode_Errors(t *testing.T) { + tests := []struct { + name string + server *fakeDeviceServer + wantErr string + wantSentinel error + }{ + { + name: "wrong confirmation code", + server: &fakeDeviceServer{}, + wantErr: "Invalid confirmation code (error code: invalid_request)", + wantSentinel: ErrInvalidConfirmationCode, + }, + { + name: "unknown device code", + server: &fakeDeviceServer{ + tokenStatus: http.StatusBadRequest, + tokenBody: `{"error": "invalid_request", "error_description": "Invalid device code"}`, + }, + wantErr: "Invalid device code (error code: invalid_request)", + wantSentinel: ErrInvalidDeviceCode, + }, + { + name: "token request rate limited", + server: &fakeDeviceServer{ + tokenStatus: http.StatusTooManyRequests, + tokenBody: `{"error": "invalid_request", "error_description": "Device grant rate limit exceeded"}`, + }, + wantErr: "Device grant rate limit exceeded (error code: invalid_request)", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := newDeviceCodeAuth(t, tc.server, true) + _, err := p.ExchangeDeviceCode(t.Context(), testDeviceCode, "AAAAAAAA") + assert.EqualError(t, err, tc.wantErr) + for _, sentinel := range []error{ErrInvalidConfirmationCode, ErrInvalidDeviceCode} { + assert.Equal(t, errors.Is(sentinel, tc.wantSentinel), errors.Is(err, sentinel), sentinel) + } + _, ok := errors.AsType[*httpclient.HttpError](err) + assert.True(t, ok, "want *httpclient.HttpError in %v", err) + assert.Equal(t, int32(1), tc.server.tokenRequests.Load()) + }) + } +} + func TestNormalizeCode(t *testing.T) { tests := []struct { in string @@ -412,6 +495,6 @@ func TestNormalizeCode(t *testing.T) { {" FTFA QQKY \n", "FTFAQQKY"}, } for _, tc := range tests { - assert.Equal(t, tc.want, normalizeCode(tc.in), tc.in) + assert.Equal(t, tc.want, NormalizeCode(tc.in), tc.in) } } From 997526ff6929b1df9a66bb9580050dd585a29915 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 28 Oct 2026 12:18:00 +0000 Subject: [PATCH 2/5] Prefix ExchangeDeviceCode errors with "token exchange" RequestDeviceAuthorization errors already say which step failed, and DeviceCodeChallenge adds the same prefix to its token exchange errors. Co-authored-by: Isaac --- libs/auth/u2m/device_code.go | 6 +++++- libs/auth/u2m/device_code_test.go | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index a5e5a9c82ed..fc7983f3b24 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -148,7 +148,11 @@ func (a *PersistentAuth) ExchangeDeviceCode(ctx context.Context, deviceCode, con if err != nil { return nil, err } - return a.exchangeConfirmationCode(ctx, cfg, deviceCode, confirmationCode) + t, err := a.exchangeConfirmationCode(ctx, cfg, deviceCode, confirmationCode) + if err != nil { + return nil, fmt.Errorf("token exchange: %w", err) + } + return t, nil } // requestDeviceAuthorization makes the RFC 8628 device authorization request diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index c6d410da4df..4fedc7bf75f 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -448,7 +448,7 @@ func TestExchangeDeviceCode_Errors(t *testing.T) { { name: "wrong confirmation code", server: &fakeDeviceServer{}, - wantErr: "Invalid confirmation code (error code: invalid_request)", + wantErr: "token exchange: Invalid confirmation code (error code: invalid_request)", wantSentinel: ErrInvalidConfirmationCode, }, { @@ -457,7 +457,7 @@ func TestExchangeDeviceCode_Errors(t *testing.T) { tokenStatus: http.StatusBadRequest, tokenBody: `{"error": "invalid_request", "error_description": "Invalid device code"}`, }, - wantErr: "Invalid device code (error code: invalid_request)", + wantErr: "token exchange: Invalid device code (error code: invalid_request)", wantSentinel: ErrInvalidDeviceCode, }, { @@ -466,7 +466,7 @@ func TestExchangeDeviceCode_Errors(t *testing.T) { tokenStatus: http.StatusTooManyRequests, tokenBody: `{"error": "invalid_request", "error_description": "Device grant rate limit exceeded"}`, }, - wantErr: "Device grant rate limit exceeded (error code: invalid_request)", + wantErr: "token exchange: Device grant rate limit exceeded (error code: invalid_request)", }, } for _, tc := range tests { From 113e77286fb46b13ff009d0d78d6ce4f383c2a60 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 28 Oct 2026 14:25:50 +0000 Subject: [PATCH 3/5] Assert each sentinel explicitly in TestExchangeDeviceCode_Errors Co-authored-by: Isaac --- libs/auth/u2m/device_code_test.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 4fedc7bf75f..59a15320c26 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -440,16 +440,17 @@ func TestExchangeDeviceCode(t *testing.T) { func TestExchangeDeviceCode_Errors(t *testing.T) { tests := []struct { - name string - server *fakeDeviceServer - wantErr string - wantSentinel error + name string + server *fakeDeviceServer + wantErr string + wantInvalidConfirmationCode bool + wantInvalidDeviceCode bool }{ { - name: "wrong confirmation code", - server: &fakeDeviceServer{}, - wantErr: "token exchange: Invalid confirmation code (error code: invalid_request)", - wantSentinel: ErrInvalidConfirmationCode, + name: "wrong confirmation code", + server: &fakeDeviceServer{}, + wantErr: "token exchange: Invalid confirmation code (error code: invalid_request)", + wantInvalidConfirmationCode: true, }, { name: "unknown device code", @@ -457,8 +458,8 @@ func TestExchangeDeviceCode_Errors(t *testing.T) { tokenStatus: http.StatusBadRequest, tokenBody: `{"error": "invalid_request", "error_description": "Invalid device code"}`, }, - wantErr: "token exchange: Invalid device code (error code: invalid_request)", - wantSentinel: ErrInvalidDeviceCode, + wantErr: "token exchange: Invalid device code (error code: invalid_request)", + wantInvalidDeviceCode: true, }, { name: "token request rate limited", @@ -474,9 +475,8 @@ func TestExchangeDeviceCode_Errors(t *testing.T) { p := newDeviceCodeAuth(t, tc.server, true) _, err := p.ExchangeDeviceCode(t.Context(), testDeviceCode, "AAAAAAAA") assert.EqualError(t, err, tc.wantErr) - for _, sentinel := range []error{ErrInvalidConfirmationCode, ErrInvalidDeviceCode} { - assert.Equal(t, errors.Is(sentinel, tc.wantSentinel), errors.Is(err, sentinel), sentinel) - } + assert.Equal(t, tc.wantInvalidConfirmationCode, errors.Is(err, ErrInvalidConfirmationCode)) + assert.Equal(t, tc.wantInvalidDeviceCode, errors.Is(err, ErrInvalidDeviceCode)) _, ok := errors.AsType[*httpclient.HttpError](err) assert.True(t, ok, "want *httpclient.HttpError in %v", err) assert.Equal(t, int32(1), tc.server.tokenRequests.Load()) From fc8cd8daf15690097d62a25ddc7d1176f075a091 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 28 Oct 2026 13:30:37 +0000 Subject: [PATCH 4/5] Move the interactive device code login tests to the terminal emulator The acceptance tests of the interactive device code login pipe the confirmation code into stdin, while users type it into a terminal. Run the login in the terminal emulator against the fake server instead. The tests cover re-entering a wrong or blank code, running out of attempts, closed input, the stored token, and the scopes of the device authorization request. Co-authored-by: Isaac --- .../device-code/nominal/out.databrickscfg | 16 ---- .../login/device-code/nominal/out.test.toml | 1 - .../auth/login/device-code/nominal/output.txt | 56 ------------- .../cmd/auth/login/device-code/nominal/script | 20 ----- .../auth/login/device-code/nominal/test.toml | 1 - .../device-code/wrong-code/out.test.toml | 1 - .../login/device-code/wrong-code/output.txt | 40 ---------- .../auth/login/device-code/wrong-code/script | 13 --- cmd/auth/login_device_code_test.go | 79 +++++++++++++++++++ 9 files changed, 79 insertions(+), 148 deletions(-) delete mode 100644 acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg delete mode 100644 acceptance/cmd/auth/login/device-code/nominal/out.test.toml delete mode 100644 acceptance/cmd/auth/login/device-code/nominal/output.txt delete mode 100644 acceptance/cmd/auth/login/device-code/nominal/script delete mode 100644 acceptance/cmd/auth/login/device-code/nominal/test.toml delete mode 100644 acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml delete mode 100644 acceptance/cmd/auth/login/device-code/wrong-code/output.txt delete mode 100644 acceptance/cmd/auth/login/device-code/wrong-code/script diff --git a/acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg b/acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg deleted file mode 100644 index 02b3737c466..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg +++ /dev/null @@ -1,16 +0,0 @@ -; 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 - -[scoped] -host = [DATABRICKS_URL] -workspace_id = [WORKSPACE_ID] -scopes = sql -auth_type = databricks-cli diff --git a/acceptance/cmd/auth/login/device-code/nominal/out.test.toml b/acceptance/cmd/auth/login/device-code/nominal/out.test.toml deleted file mode 100644 index 18b1a88417e..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/out.test.toml +++ /dev/null @@ -1 +0,0 @@ -Cloud = false diff --git a/acceptance/cmd/auth/login/device-code/nominal/output.txt b/acceptance/cmd/auth/login/device-code/nominal/output.txt deleted file mode 100644 index 2216533b304..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/output.txt +++ /dev/null @@ -1,56 +0,0 @@ - -=== Login with a host - ->>> [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, enter the confirmation code provided in your browser. -Confirmation code: 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 - -=== 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, enter the confirmation code provided in your browser. -Confirmation code: Profile test was successfully saved - ->>> [CLI] auth token --profile test -json.access_token = "oauth-token"; - ->>> 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"} - -=== Login with custom scopes - ->>> [CLI] auth login --host [DATABRICKS_URL] --profile scoped --use-device-code --scopes sql -Using a browser on another device, visit: -[DATABRICKS_URL]/device/verification - -And enter the code: -WDJB-MJHT - -Once finished, enter the confirmation code provided in your browser. -Confirmation code: 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-3&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} diff --git a/acceptance/cmd/auth/login/device-code/nominal/script b/acceptance/cmd/auth/login/device-code/nominal/script deleted file mode 100644 index f1d6c04f59a..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/script +++ /dev/null @@ -1,20 +0,0 @@ -sethome "./home" - -title "Login with a host\n" -printf "FTFA-QQKY\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code -trace $CLI auth token --profile test | gron.py | grep access_token -trace $CLI auth profiles - -title "Login again with the host from the saved profile\n" -printf "FTFA-QQKY\n" | trace $CLI auth login --profile test --use-device-code -trace $CLI auth token --profile test | gron.py | grep access_token -trace print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token - -# The token gets the scopes requested at device authorization, so --scopes is -# sent there and not with the token request. -title "Login with custom scopes\n" -printf "FTFA-QQKY\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile scoped --use-device-code --scopes sql -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/nominal/test.toml b/acceptance/cmd/auth/login/device-code/nominal/test.toml deleted file mode 100644 index 159efe02696..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/test.toml +++ /dev/null @@ -1 +0,0 @@ -RecordRequests = true diff --git a/acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml b/acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml deleted file mode 100644 index 18b1a88417e..00000000000 --- a/acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml +++ /dev/null @@ -1 +0,0 @@ -Cloud = false diff --git a/acceptance/cmd/auth/login/device-code/wrong-code/output.txt b/acceptance/cmd/auth/login/device-code/wrong-code/output.txt deleted file mode 100644 index 2ba7f3ea23e..00000000000 --- a/acceptance/cmd/auth/login/device-code/wrong-code/output.txt +++ /dev/null @@ -1,40 +0,0 @@ - -=== Login fails when no confirmation code is provided - ->>> musterr [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, enter the confirmation code provided in your browser. -Confirmation code: Error: reading confirmation code: EOF - -=== Login fails after 3 wrong confirmation codes - ->>> musterr [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, enter the confirmation code provided in your browser. -Confirmation code: Invalid confirmation code (error code: invalid_request). Enter the confirmation code again (2 attempts left). -Confirmation code: Invalid confirmation code (error code: invalid_request). Enter the confirmation code again (1 attempt left). -Confirmation code: Error: Invalid confirmation code (error code: invalid_request). Start login from scratch - -=== Login succeeds after re-entering the confirmation code - ->>> [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, enter the confirmation code provided in your browser. -Confirmation code: Invalid confirmation code (error code: invalid_request). Enter the confirmation code again (2 attempts left). -Confirmation code: Invalid confirmation code (error code: invalid_request). Enter the confirmation code again (1 attempt left). -Confirmation code: Profile test was successfully saved diff --git a/acceptance/cmd/auth/login/device-code/wrong-code/script b/acceptance/cmd/auth/login/device-code/wrong-code/script deleted file mode 100644 index 3adb0de81a3..00000000000 --- a/acceptance/cmd/auth/login/device-code/wrong-code/script +++ /dev/null @@ -1,13 +0,0 @@ -sethome "./home" - -title "Login fails when no confirmation code is provided\n" -trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code < /dev/null - -title "Login fails after 3 wrong confirmation codes\n" -printf "AAAA-AAAA\nBBBB-BBBB\nCCCC-CCCC\n" | trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code - -# A wrong confirmation code leaves the device code valid, so the user can -# enter it again. A blank line counts as a wrong confirmation code. -# The confirmation code is accepted the way the browser displays it, in any case. -title "Login succeeds after re-entering the confirmation code\n" -printf "AAAA-AAAA\n\nftfa-qqky\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index d38d2543b7c..e806d9f5c15 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -3,8 +3,12 @@ package auth import ( "bytes" "context" + "encoding/json" "errors" "io" + "net/url" + "os" + "path/filepath" "runtime" "strings" "testing" @@ -17,6 +21,7 @@ import ( "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/testserver" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -304,3 +309,77 @@ resources = https://workspace.test/ai-gateway/mcp-services/system.ai.github _, err := tm.Result() assert.EqualError(t, err, "^C") } + +// newDeviceCodeServer returns a fake workspace with device code login. +func newDeviceCodeServer(t *testing.T) *testserver.Server { + server := testserver.New(t) + t.Cleanup(server.Close) + testserver.AddDefaultHandlers(server) + return server +} + +// A wrong or blank confirmation code leaves the device code valid, so the user +// can enter it again. The code is accepted the way the browser displays it, in +// any case. +func TestLoginDeviceCodeReadsConfirmationCode(t *testing.T) { + server := newDeviceCodeServer(t) + tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test") + tm.WaitFor("Confirmation code") + tm.Type("AAAA-AAAA\n") + tm.WaitFor("Enter the confirmation code again (2 attempts left)") + tm.Type("\n") + tm.WaitFor("Enter the confirmation code again (1 attempt left)") + tm.Type("ftfa-qqky\n") + _, err := tm.Result() + require.NoError(t, err) + + cfg, err := os.ReadFile(os.Getenv("DATABRICKS_CONFIG_FILE")) + require.NoError(t, err) + assert.Regexp(t, `auth_type\s+= databricks-cli`, string(cfg)) + + 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, "test") +} + +func TestLoginDeviceCodeFailsAfterWrongConfirmationCodes(t *testing.T) { + server := newDeviceCodeServer(t) + tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test") + tm.WaitFor("Confirmation code") + tm.Type("AAAA-AAAA\nBBBB-BBBB\nCCCC-CCCC\n") + _, err := tm.Result() + assert.EqualError(t, err, "Invalid confirmation code (error code: invalid_request). Start login from scratch") +} + +func TestLoginDeviceCodeFailsWithoutConfirmationCode(t *testing.T) { + server := newDeviceCodeServer(t) + tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test") + tm.WaitFor("Confirmation code") + tm.Close() + _, err := tm.Result() + assert.EqualError(t, err, "reading confirmation code: EOF") +} + +// The token gets the scopes requested at device authorization, so --scopes is +// sent there and not with the token request. +func TestLoginDeviceCodeRequestsScopes(t *testing.T) { + server := newDeviceCodeServer(t) + var scope string + server.RequestCallback = func(r *testserver.Request) { + if r.URL.Path == "/oidc/v1/device/auth" { + form, err := url.ParseQuery(string(r.Body)) + assert.NoError(t, err) + scope = form.Get("scope") + } + } + tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test", "--scopes", "sql") + tm.WaitFor("Confirmation code") + tm.Type("FTFA-QQKY\n") + _, err := tm.Result() + require.NoError(t, err) + assert.Equal(t, "offline_access sql", scope) +} From bcf2f9606427c5b99670125a5de0a96f7c2c4518 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 28 Oct 2026 14:44:57 +0000 Subject: [PATCH 5/5] Assert the saved profile in the device code login tests Check the host, workspace ID, auth type and scopes of the saved profile instead of matching auth_type in the raw file. The scopes test now starts from an empty config with --host, so creating a profile from the flag is covered too. Clear the environment so DATABRICKS_* variables of the developer don't end up in the saved profile. Co-authored-by: Isaac --- cmd/auth/login_device_code_test.go | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index e806d9f5c15..a95f5a7d2da 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -10,10 +10,12 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "testing" "time" + "github.com/databricks/cli/internal/testutil" "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/cmdio" @@ -216,6 +218,7 @@ 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{}] { + testutil.CleanupEnvironment(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) @@ -224,7 +227,9 @@ func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest t.Setenv(deviceCodeEnvVar, "true") return termtest.Run(t, func(ctx context.Context) (struct{}, error) { - cmd := newLoginCommand(&auth.AuthArguments{}) + 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...)) @@ -313,7 +318,6 @@ resources = https://workspace.test/ai-gateway/mcp-services/system.ai.github // newDeviceCodeServer returns a fake workspace with device code login. func newDeviceCodeServer(t *testing.T) *testserver.Server { server := testserver.New(t) - t.Cleanup(server.Close) testserver.AddDefaultHandlers(server) return server } @@ -333,9 +337,12 @@ func TestLoginDeviceCodeReadsConfirmationCode(t *testing.T) { _, err := tm.Result() require.NoError(t, err) - cfg, err := os.ReadFile(os.Getenv("DATABRICKS_CONFIG_FILE")) + p, err := loadProfileByName(t.Context(), "test", profile.DefaultProfiler) require.NoError(t, err) - assert.Regexp(t, `auth_type\s+= databricks-cli`, string(cfg)) + require.NotNil(t, p) + assert.Equal(t, server.URL, p.Host) + assert.Equal(t, strconv.Itoa(testserver.TestWorkspaceID), p.WorkspaceID) + assert.Equal(t, authTypeDatabricksCLI, p.AuthType) raw, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".databricks", "token-cache.json")) require.NoError(t, err) @@ -376,10 +383,17 @@ func TestLoginDeviceCodeRequestsScopes(t *testing.T) { scope = form.Get("scope") } } - tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test", "--scopes", "sql") + tm := startDeviceCodeLogin(t, "", "--host", server.URL, "--profile", "test", "--scopes", "sql") tm.WaitFor("Confirmation code") tm.Type("FTFA-QQKY\n") _, err := tm.Result() require.NoError(t, err) assert.Equal(t, "offline_access sql", scope) + + p, err := loadProfileByName(t.Context(), "test", profile.DefaultProfiler) + require.NoError(t, err) + require.NotNil(t, p) + assert.Equal(t, server.URL, p.Host) + assert.Equal(t, strconv.Itoa(testserver.TestWorkspaceID), p.WorkspaceID) + assert.Equal(t, "sql", p.Scopes) }