From 5d228605f4ca9a11cedc2c7c239c666bb70cd1f9 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Thu, 15 Oct 2026 13:11:11 +0000 Subject: [PATCH 01/24] Add experimental device code login (auth login --use-device-code) Co-authored-by: Isaac --- cmd/auth/README.md | 52 +++++++ cmd/auth/login.go | 58 +++++++- cmd/auth/login_device_code.go | 111 ++++++++++++++ docs/device-code-login.md | 67 +++++++++ libs/auth/u2m/device_code.go | 228 +++++++++++++++++++++++++++++ libs/auth/u2m/endpoint_supplier.go | 4 + libs/auth/u2m/persistent_auth.go | 7 +- libs/cmdio/ask.go | 24 +++ 8 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 cmd/auth/login_device_code.go create mode 100644 docs/device-code-login.md create mode 100644 libs/auth/u2m/device_code.go diff --git a/cmd/auth/README.md b/cmd/auth/README.md index 3185ee63d66..436c88d78eb 100644 --- a/cmd/auth/README.md +++ b/cmd/auth/README.md @@ -49,3 +49,55 @@ sequenceDiagram CLI -X User: no auth configured end ``` + +# Device code login (experimental) + +`databricks auth login --use-device-code` logs in from a machine without a browser. +It is a proof of concept: the flag is hidden and requires +`DATABRICKS_CLI_DEVICE_CODE=true`. Only workspace hosts are supported. See +[docs/device-code-login.md](../../docs/device-code-login.md) for the user guide. + +Databricks implements a variant of [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628): +instead of polling the token endpoint until the user approves the login, the +browser shows the user a confirmation code, which the CLI sends together with +the device code in a single token request. + +```mermaid +sequenceDiagram + autonumber + actor User + + User ->> CLI: type `databricks auth login HOST --use-device-code` + CLI ->>+ HOST: request OIDC endpoints + HOST ->>- CLI: token & device_authorization endpoints + CLI ->>+ Device Auth Endpoint: client_id + scopes + Device Auth Endpoint ->>- CLI: DEVICE CODE + USER CODE + verification URI + CLI ->> User: show verification URI + USER CODE + + User ->>+ Verification Page: open on another device, go through SSO + User ->> Verification Page: enter USER CODE and approve + Verification Page ->>- User: CONFIRMATION CODE + + User ->> CLI: type CONFIRMATION CODE + CLI ->>+ Token Endpoint: Exchange: DEVICE CODE + CONFIRMATION CODE + Token Endpoint ->>- CLI: Access Token (JWT) + refresh + expiry + CLI ->> Token cache: Save Access Token (JWT) + refresh + expiry + CLI ->> User: success +``` + +Notes on the server behavior the CLI relies on: + +- Workspaces advertise `device_authorization_endpoint` in + `/oidc/.well-known/oauth-authorization-server` only when device code login + is enabled for them. The CLI fails early if it is missing. +- The token request carries the confirmation code in the `confirmation_code` + form parameter. A wrong confirmation code does not consume the device code; + a successful exchange does. The server answers a wrong confirmation code + with `invalid_request`, and the CLI lets the user enter it up to 3 times in + total. +- Approving the login in the browser extends the device code's lifetime, so + the CLI does not enforce the `expires_in` from the device authorization + response. +- 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. diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 6019e8b90a0..7d0b6c11e43 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -49,6 +49,9 @@ const ( // host used by the discovery login flow. Intended for testing and // development against non-production environments. discoveryHostEnvVar = "DATABRICKS_DISCOVERY_HOST" + // deviceCodeEnvVar enables the experimental device code login flow + // (--use-device-code) while it is a proof of concept. + deviceCodeEnvVar = "DATABRICKS_CLI_DEVICE_CODE" ) // discoveryErr wraps an error (or creates a new one) and appends the @@ -151,6 +154,7 @@ a new profile is created. var scopes string var clientID string var resources []string + var useDeviceCode bool cmd.Flags().DurationVar(&loginTimeout, "timeout", defaultTimeout, "Timeout for completing login challenge in the browser") cmd.Flags().BoolVar(&configureCluster, "configure-cluster", false, @@ -165,6 +169,10 @@ a new profile is created. "OAuth client ID to use for U2M authentication") cmd.Flags().StringArrayVar(&resources, "resource", nil, "RFC 8707 resource indicator to scope the login to (repeatable). Requires --host.") + cmd.Flags().BoolVar(&useDeviceCode, "use-device-code", false, + "Log in with a device code, using a browser on another device (experimental)") + // Hidden while device code login is an experimental proof of concept. + cmd.Flags().MarkHidden("use-device-code") cmd.PreRunE = profileHostConflictCheck @@ -197,6 +205,20 @@ a new profile is created. } } + // Check a known host before the keyring probe and any prompt, so that + // an unsupported host or workspace fails right away. A host that is + // only known later is checked as soon as it is known. + if useDeviceCode { + if err := validateDeviceCodeLogin(ctx, cmd); err != nil { + return err + } + if authArguments.Host != "" { + if err := validateDeviceCodeHost(ctx, authArguments); err != nil { + return err + } + } + } + // Resolve the cache before the browser step so an unavailable // keyring surfaces here rather than after OAuth. The probe also // triggers the OS unlock prompt, which the user can answer during @@ -239,7 +261,17 @@ a new profile is created. return err } authArguments.Host = host + if useDeviceCode { + if err := validateDeviceCodeHost(ctx, authArguments); err != nil { + return err + } + } case profilePickerCreateNew: + // A new profile without a host uses the discovery login, + // which device code login does not support. + if useDeviceCode { + return errDeviceCodeNeedsHost + } // Fall through to the profile name prompt below. } } @@ -284,11 +316,19 @@ a new profile is created. } if len(resources) == 0 { resources = u2mResourcesFromProfile(existingProfile) + // Device code login cannot scope the token to these resources, so + // the profile would claim a scoping its token does not have. + if useDeviceCode && len(resources) > 0 { + return fmt.Errorf("--use-device-code cannot be used with profile %s because its login is scoped to resources. Log in without --use-device-code", profileName) + } } // If no host is available from any source, use the discovery flow // via login.databricks.com. if shouldUseDiscovery(authArguments.Host, args, existingProfile) { + if useDeviceCode { + return errDeviceCodeNeedsHost + } if err := validateDiscoveryFlagCompatibility(cmd); err != nil { return err } @@ -305,6 +345,17 @@ a new profile is created. }) } + // Without a host from the flags or the picker, the host comes from the + // profile. Check it before setHostAndAccountId, which prompts for an + // account ID for account hosts. + if useDeviceCode && authArguments.Host == "" && existingProfile != nil { + profileArguments := *authArguments + profileArguments.Host = existingProfile.Host + if err := validateDeviceCodeHost(ctx, &profileArguments); err != nil { + return err + } + } + err = setHostAndAccountId(ctx, existingProfile, authArguments, args) if err != nil { return err @@ -350,7 +401,12 @@ a new profile is created. } defer persistentAuth.Close() - token, err := persistentAuth.Challenge() + var token *oauth2.Token + if useDeviceCode { + token, err = persistentAuth.DeviceCodeChallenge(promptForConfirmationCode) + } else { + token, err = persistentAuth.Challenge() + } if err != nil { return err } diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go new file mode 100644 index 00000000000..d7df95c3c41 --- /dev/null +++ b/cmd/auth/login_device_code.go @@ -0,0 +1,111 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/auth/u2m" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/env" + "github.com/databricks/databricks-sdk-go/config" + "github.com/spf13/cobra" +) + +// deviceCodeIncompatibleFlags lists flags that device code login cannot honor. +var deviceCodeIncompatibleFlags = []string{ + // Resource indicators are sent on the browser authorization request, + // which the device code flow does not make. + "resource", + // Device code login is only supported for workspace hosts. + "skip-workspace", +} + +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") +) + +// validateDeviceCodeHost returns an error if the host is an account or unified +// host, or a workspace without device code login enabled. It checks the same +// signals that make setHostAndAccountId prompt for an account ID, so that it +// can run before the user is prompted for anything. +func validateDeviceCodeHost(ctx context.Context, authArguments *auth.AuthArguments) error { + // Work on a copy: host discovery fills in the account ID even for + // workspace hosts, which must not change the default profile name. + probe := *authArguments + probe.Host = auth.ExtractHostQueryParams(probe.Host).Host + if needsAccountIDPrompt(probe.Host, probe.DiscoveryURL) { + return errDeviceCodeWorkspaceOnly + } + if probe.DiscoveryURL == "" { + runHostDiscovery(ctx, &probe) + if needsAccountIDPrompt(probe.Host, probe.DiscoveryURL) { + return errDeviceCodeWorkspaceOnly + } + } + return u2m.CheckDeviceCodeSupported(ctx, (&config.Config{Host: probe.Host}).CanonicalHostName()) +} + +// validateDeviceCodeLogin returns an error if device code login is not enabled +// or is combined with flags it cannot honor. +func validateDeviceCodeLogin(ctx context.Context, cmd *cobra.Command) error { + if enabled, _ := env.GetBool(ctx, deviceCodeEnvVar); !enabled { + return fmt.Errorf("--use-device-code is experimental. Set %s=true to enable it", deviceCodeEnvVar) + } + for _, name := range deviceCodeIncompatibleFlags { + // Compare with the default so that, e.g., --skip-workspace=false is + // accepted. + if f := cmd.Flag(name); f.Changed && f.Value.String() != f.DefValue { + return fmt.Errorf("--use-device-code cannot be combined with --%s", name) + } + } + return nil +} + +// 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 +// and asks again. +func promptForConfirmationCode(ctx context.Context, da u2m.DeviceAuthorization, lastErr error, attemptsLeft int) (string, error) { + if lastErr == nil { + cmdio.LogString(ctx, strings.Join([]string{ + "Using a browser on another device, visit:", + da.VerificationURI, + "", + "And enter the code:", + formatCode(da.UserCode), + "", + "Once finished, enter the confirmation code provided in your browser.", + }, "\n")) + } else { + attempts := "attempts" + if attemptsLeft == 1 { + attempts = "attempt" + } + cmdio.LogString(ctx, fmt.Sprintf("%v. Enter the confirmation code again (%d %s left).", lastErr, attemptsLeft, attempts)) + } + + // Ask again on a blank line, e.g. when the user pressed Enter by accident. + // A cancelled read fails the login, so stdin is not read again after it. + for { + code, err := cmdio.AskCancellable(ctx, "Confirmation code", "") + if err != nil { + return "", fmt.Errorf("reading confirmation code: %w", err) + } + if strings.TrimSpace(code) != "" { + return code, nil + } + } +} + +// formatCode displays an 8-character device code as two groups of four +// separated by a dash, matching how the browser displays codes. +func formatCode(code string) string { + if len(code) != 8 { + return code + } + return code[:4] + "-" + code[4:] +} diff --git a/docs/device-code-login.md b/docs/device-code-login.md new file mode 100644 index 00000000000..70e749c1291 --- /dev/null +++ b/docs/device-code-login.md @@ -0,0 +1,67 @@ +# Device code login (experimental) + +Device code login authenticates the Databricks CLI on machines that cannot open +a browser, such as headless servers, SSH sessions, and web terminals. You +approve the login in a browser on another device (for example your laptop or +phone), and type the confirmation code it shows you back into the CLI. + +> [!WARNING] +> Device code login is an experimental proof of concept. It is disabled by +> default, may change without notice, and only supports workspace hosts. +> Account hosts and unified (SPOG) hosts are not supported yet. + +## Prerequisites + +- The workspace must have device code login enabled. If it is not, the CLI + fails with `device code login is not enabled for this workspace`. +- Enable the feature in the CLI by setting `DATABRICKS_CLI_DEVICE_CODE=true`. + +## Log in from a headless machine + +```console +$ export DATABRICKS_CLI_DEVICE_CODE=true +$ databricks auth login --host https:// --profile my-profile --use-device-code +Using a browser on another device, visit: +https:///device/verification + +And enter the code: +WDJB-MJHT + +Once finished, enter the confirmation code provided in your browser. +Confirmation code: FTFA-QQKY +Profile my-profile was successfully saved +``` + +1. Run `databricks auth login` with `--use-device-code`. The host can be passed as + an argument, with `--host`, or come from an existing profile. +2. On another device, open the URL that the CLI shows, sign in to the + workspace if needed, and enter the code. The code expires after about 10 + minutes. +3. Confirm that you started this login yourself. Only approve a login that you + initiated: approving it grants the CLI access to the Databricks API as you. +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. + +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`. + +## Session lifetime + +The token is renewed automatically for up to 24 hours. After that, commands +fail with a token refresh error and you need to run the login again. + +## Troubleshooting + +| Error or symptom | Likely cause | What to do | +| --- | --- | --- | +| `--use-device-code is experimental. Set DATABRICKS_CLI_DEVICE_CODE=true to enable it` | The feature is not enabled in the CLI. | Set `DATABRICKS_CLI_DEVICE_CODE=true`. | +| `device code login is not enabled for this workspace` | The workspace does not advertise device code login. | Use a workspace where the feature is enabled. | +| `--use-device-code is only supported for workspace hosts` | The host is an account or unified host. | Log in to a workspace host instead. | +| The browser says the code is invalid or expired | The code was mistyped, or more than ~10 minutes passed. | Re-enter the code carefully, or run the login again for a new code. Codes never contain `0`, `1`, `2`, `O`, `Z`, or `I`. | +| `Invalid confirmation code` | The confirmation code was mistyped. | Re-enter the code. After 3 rejected codes, the login fails with `Invalid confirmation code (error code: invalid_request). Start login from scratch` and you need to run it again. | +| `token exchange: Invalid device code` | The login was already completed or expired. | 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. | +| `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. | diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go new file mode 100644 index 00000000000..0661bd4bdbc --- /dev/null +++ b/libs/auth/u2m/device_code.go @@ -0,0 +1,228 @@ +package u2m + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/databricks/databricks-sdk-go/httpclient" + "golang.org/x/oauth2" +) + +// confirmationCodeParam is the Databricks extension to the RFC 8628 token +// request. The user receives the confirmation code in the browser after +// approving the login, and the client submits it together with the device +// code instead of polling the token endpoint until the login is approved. +const confirmationCodeParam = "confirmation_code" + +// deviceCodeGrantType is the grant type of the RFC 8628 token request. +const deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" + +// ErrDeviceCodeNotSupported is returned when the workspace does not advertise +// a device authorization endpoint in its OIDC discovery document. +var ErrDeviceCodeNotSupported = errors.New("device code login is not enabled for this workspace") + +// DeviceAuthorization holds what the user needs to approve a device code +// login from another device. +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 +} + +// maxConfirmationAttempts is how many confirmation codes the user can enter +// for a single device code before the login fails. +const maxConfirmationAttempts = 3 + +// ConfirmFunc shows the device authorization to the user and returns the +// confirmation code that the user received after approving the login. When +// the server rejects a confirmation code, it is called again with the +// server's error in lastErr so that the user can re-enter the code. lastErr +// is nil on the first call. attemptsLeft is the number of confirmation codes +// the user can still enter, including this one. +type ConfirmFunc func(ctx context.Context, da DeviceAuthorization, lastErr error, attemptsLeft int) (string, error) + +// CheckDeviceCodeSupported returns ErrDeviceCodeNotSupported if the workspace +// does not advertise a device authorization endpoint in its OIDC discovery +// document. Callers use it to fail before prompting the user for anything. +func CheckDeviceCodeSupported(ctx context.Context, workspaceHost string) error { + supplier := &BasicOAuthEndpointSupplier{Client: httpclient.NewApiClient(httpclient.ClientConfig{})} + endpoints, err := supplier.GetWorkspaceOAuthEndpoints(ctx, workspaceHost) + if err != nil { + return fmt.Errorf("fetching OAuth endpoints: %w", err) + } + if endpoints.DeviceAuthorizationEndpoint == "" { + return ErrDeviceCodeNotSupported + } + return nil +} + +// DeviceCodeChallenge logs in using the Databricks variant of the RFC 8628 +// device authorization grant. It requests a device authorization, calls +// confirm with the user code and verification URI, and exchanges the device +// code together with the returned confirmation code for a token. A rejected +// confirmation code leaves the device code valid, so the user can re-enter +// it up to maxConfirmationAttempts times. Only workspace hosts are supported. +// The caller is responsible for storing the returned token. +func (a *PersistentAuth) DeviceCodeChallenge(confirm ConfirmFunc) (*oauth2.Token, error) { + if _, ok := a.oAuthArgument.(WorkspaceOAuthArgument); !ok { + return nil, errors.New("device code login is only supported for workspace hosts") + } + cfg, err := a.oauth2Config() + if err != nil { + return nil, fmt.Errorf("fetching oauth config: %w", err) + } + if cfg.Endpoint.DeviceAuthURL == "" { + return nil, ErrDeviceCodeNotSupported + } + + ctx := a.setOAuthContext(a.ctx) + da, err := cfg.DeviceAuth(ctx) + if err != nil { + return nil, fmt.Errorf("device authorization: %w", oauthError(err)) + } + + authorization := DeviceAuthorization{ + UserCode: da.UserCode, + VerificationURI: da.VerificationURI, + } + var lastErr error + for attempt := range maxConfirmationAttempts { + code, err := confirm(a.ctx, authorization, lastErr, maxConfirmationAttempts-attempt) + if err != nil { + return nil, err + } + + t, err := a.exchangeDeviceCode(ctx, cfg, da.DeviceCode, normalizeCode(code)) + if err == nil { + return t, nil + } + lastErr = oauthError(err) + // The server answers a wrong confirmation code with invalid_request. + // Any other error cannot be fixed by entering the code again. + if errCode, _ := oauthErrorResponse(err); errCode != "invalid_request" { + return nil, fmt.Errorf("token exchange: %w", lastErr) + } + } + return nil, fmt.Errorf("%w. Start login from scratch", lastErr) +} + +// exchangeDeviceCode makes a single RFC 8628 device access token request. +// The server issues the token as soon as it receives a valid confirmation +// code, so unlike oauth2.Config.DeviceAccessToken this does not wait before +// the request or poll while the login is pending. It also does not enforce +// the expiry from the device authorization response, because the server +// extends the device code's lifetime when the user approves the login. +func (a *PersistentAuth) exchangeDeviceCode(ctx context.Context, cfg *oauth2.Config, deviceCode, confirmationCode string) (*oauth2.Token, error) { + // No scope: the token gets the scopes of the device authorization + // request, and the server ignores a scope sent here. + form := url.Values{ + "grant_type": {deviceCodeGrantType}, + "client_id": {cfg.ClientID}, + "device_code": {deviceCode}, + confirmationCodeParam: {confirmationCode}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.Endpoint.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("reading token response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + retrieveErr := &oauth2.RetrieveError{Response: resp, Body: body} + var errResp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if json.Unmarshal(body, &errResp) == nil { + retrieveErr.ErrorCode = errResp.Error + retrieveErr.ErrorDescription = errResp.ErrorDescription + } + return nil, retrieveErr + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + } + var raw map[string]any + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("parsing token response: %w", err) + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parsing token response: %w", err) + } + if tokenResp.AccessToken == "" { + return nil, errors.New("server response missing access_token") + } + t := &oauth2.Token{ + AccessToken: tokenResp.AccessToken, + TokenType: tokenResp.TokenType, + RefreshToken: tokenResp.RefreshToken, + ExpiresIn: tokenResp.ExpiresIn, + } + if tokenResp.ExpiresIn > 0 { + t.Expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + } + return t.WithExtra(raw), nil +} + +// 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 { + return strings.NewReplacer("-", "", " ", "").Replace(strings.ToUpper(strings.TrimSpace(code))) +} + +// oauthError extracts the RFC 6749 §5.2 error response from err so that users +// see the server's explanation instead of a raw HTTP response body. +func oauthError(err error) error { + code, description := oauthErrorResponse(err) + if code == "" { + return err + } + return fmt.Errorf("%s (error code: %s)", description, code) +} + +// oauthErrorResponse returns the error code and description of the RFC 6749 +// §5.2 error response carried by err. The code is empty if err does not carry +// one. +func oauthErrorResponse(err error) (code, description string) { + // The default RoundTripper of httpclient.ApiClient returns an error for + // non-2xx responses, so the oauth2 library never parses the body itself. + if httpErr, ok := errors.AsType[*httpclient.HttpError](err); ok { + var resp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if json.Unmarshal([]byte(httpErr.Message), &resp) != nil { + return "", "" + } + return resp.Error, resp.ErrorDescription + } + if retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err); ok { + return retrieveErr.ErrorCode, retrieveErr.ErrorDescription + } + return "", "" +} diff --git a/libs/auth/u2m/endpoint_supplier.go b/libs/auth/u2m/endpoint_supplier.go index f5065aa1c61..987f75ead24 100644 --- a/libs/auth/u2m/endpoint_supplier.go +++ b/libs/auth/u2m/endpoint_supplier.go @@ -78,4 +78,8 @@ type OAuthAuthorizationServer struct { // TokenEndpoint is the URL to exchange an authorization code for an access token. // It typically ends with /v1/token. TokenEndpoint string `json:"token_endpoint"` + + // DeviceAuthorizationEndpoint is the RFC 8628 device authorization endpoint. + // Workspaces only advertise it when device code login is enabled for them. + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` } diff --git a/libs/auth/u2m/persistent_auth.go b/libs/auth/u2m/persistent_auth.go index a87e37783f4..58986cc9c80 100644 --- a/libs/auth/u2m/persistent_auth.go +++ b/libs/auth/u2m/persistent_auth.go @@ -647,9 +647,10 @@ func (a *PersistentAuth) oauth2Config() (*oauth2.Config, error) { return &oauth2.Config{ ClientID: a.clientID, Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: endpoints.TokenEndpoint, - AuthStyle: oauth2.AuthStyleInParams, + AuthURL: authURL, + TokenURL: endpoints.TokenEndpoint, + DeviceAuthURL: endpoints.DeviceAuthorizationEndpoint, + AuthStyle: oauth2.AuthStyleInParams, }, RedirectURL: "http://" + a.redirectAddr, Scopes: scopes, diff --git a/libs/cmdio/ask.go b/libs/cmdio/ask.go index 909076d5e60..82772c6d38d 100644 --- a/libs/cmdio/ask.go +++ b/libs/cmdio/ask.go @@ -65,6 +65,30 @@ func Ask(ctx context.Context, question, defaultVal string) (string, error) { return ans, nil } +// AskCancellable is like Ask, but stops waiting for the answer when ctx is +// done and returns ctx.Err(). Reading the input cannot be interrupted, so the +// read keeps running in the background and its result is discarded. The +// caller must not read the input again after a cancelled call, because the +// abandoned read would consume the next line. +func AskCancellable(ctx context.Context, question, defaultVal string) (string, error) { + type answer struct { + ans string + err error + } + answers := make(chan answer, 1) + go func() { + ans, err := Ask(ctx, question, defaultVal) + answers <- answer{ans, err} + }() + + select { + case <-ctx.Done(): + return "", ctx.Err() + case a := <-answers: + return a.ans, a.err + } +} + // AskYesOrNo prompts the user with a question and returns true if the answer // is "y" or "yes" (case-insensitive). Any other answer, including an empty // one, returns false. From 839cee73d4ccc9c340ca77f9cd91eb93ac92423a Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Thu, 15 Oct 2026 13:11:13 +0000 Subject: [PATCH 02/24] Add tests for device code login Co-authored-by: Isaac --- .../out.test.toml | 1 + .../output.txt | 3 + .../use-device-code-client-not-allowed/script | 4 + .../test.toml | 3 + .../use-device-code-not-enabled/out.test.toml | 1 + .../use-device-code-not-enabled/output.txt | 6 + .../login/use-device-code-not-enabled/script | 12 + .../use-device-code-not-enabled/test.toml | 14 + .../out.test.toml | 1 + .../use-device-code-unified-host/output.txt | 5 + .../login/use-device-code-unified-host/script | 9 + .../use-device-code-unified-host/test.toml | 4 + .../login/use-device-code/out.databrickscfg | 16 + .../auth/login/use-device-code/out.test.toml | 1 + .../cmd/auth/login/use-device-code/output.txt | 166 +++++++++ .../cmd/auth/login/use-device-code/script | 63 ++++ .../cmd/auth/login/use-device-code/test.toml | 4 + cmd/auth/login_device_code_test.go | 225 +++++++++++ libs/auth/u2m/device_code_test.go | 351 ++++++++++++++++++ libs/cmdio/ask_test.go | 27 ++ libs/testserver/fake_oidc.go | 102 ++++- libs/testserver/handlers.go | 4 + 22 files changed, 1020 insertions(+), 2 deletions(-) create mode 100644 acceptance/cmd/auth/login/use-device-code-client-not-allowed/out.test.toml create mode 100644 acceptance/cmd/auth/login/use-device-code-client-not-allowed/output.txt create mode 100644 acceptance/cmd/auth/login/use-device-code-client-not-allowed/script create mode 100644 acceptance/cmd/auth/login/use-device-code-client-not-allowed/test.toml create mode 100644 acceptance/cmd/auth/login/use-device-code-not-enabled/out.test.toml create mode 100644 acceptance/cmd/auth/login/use-device-code-not-enabled/output.txt create mode 100644 acceptance/cmd/auth/login/use-device-code-not-enabled/script create mode 100644 acceptance/cmd/auth/login/use-device-code-not-enabled/test.toml create mode 100644 acceptance/cmd/auth/login/use-device-code-unified-host/out.test.toml create mode 100644 acceptance/cmd/auth/login/use-device-code-unified-host/output.txt create mode 100644 acceptance/cmd/auth/login/use-device-code-unified-host/script create mode 100644 acceptance/cmd/auth/login/use-device-code-unified-host/test.toml create mode 100644 acceptance/cmd/auth/login/use-device-code/out.databrickscfg create mode 100644 acceptance/cmd/auth/login/use-device-code/out.test.toml create mode 100644 acceptance/cmd/auth/login/use-device-code/output.txt create mode 100644 acceptance/cmd/auth/login/use-device-code/script create mode 100644 acceptance/cmd/auth/login/use-device-code/test.toml create mode 100644 cmd/auth/login_device_code_test.go create mode 100644 libs/auth/u2m/device_code_test.go diff --git a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/out.test.toml b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/output.txt b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/output.txt new file mode 100644 index 00000000000..4b880689589 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/output.txt @@ -0,0 +1,3 @@ + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --client-id custom-client-id +Error: device authorization: Client is not allowed to use device code login (error code: unauthorized_client) diff --git a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/script b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/script new file mode 100644 index 00000000000..0627881dc65 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/script @@ -0,0 +1,4 @@ +sethome "./home" +export DATABRICKS_CLI_DEVICE_CODE=true + +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --client-id custom-client-id diff --git a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/test.toml b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/test.toml new file mode 100644 index 00000000000..36c0e7e237b --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] diff --git a/acceptance/cmd/auth/login/use-device-code-not-enabled/out.test.toml b/acceptance/cmd/auth/login/use-device-code-not-enabled/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-not-enabled/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/use-device-code-not-enabled/output.txt b/acceptance/cmd/auth/login/use-device-code-not-enabled/output.txt new file mode 100644 index 00000000000..eb541e82566 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-not-enabled/output.txt @@ -0,0 +1,6 @@ + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code +Error: device code login is not enabled for this workspace + +>>> musterr [CLI] auth login --profile ws --use-device-code +Error: device code login is not enabled for this workspace diff --git a/acceptance/cmd/auth/login/use-device-code-not-enabled/script b/acceptance/cmd/auth/login/use-device-code-not-enabled/script new file mode 100644 index 00000000000..087b878e2d1 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-not-enabled/script @@ -0,0 +1,12 @@ +sethome "./home" +export DATABRICKS_CLI_DEVICE_CODE=true + +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code + +# The check also applies to a host that comes from a profile. +cat > ./home/.databrickscfg << EOF +[ws] +host = $DATABRICKS_HOST +auth_type = databricks-cli +EOF +trace musterr $CLI auth login --profile ws --use-device-code diff --git a/acceptance/cmd/auth/login/use-device-code-not-enabled/test.toml b/acceptance/cmd/auth/login/use-device-code-not-enabled/test.toml new file mode 100644 index 00000000000..c1cdcb5caad --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-not-enabled/test.toml @@ -0,0 +1,14 @@ +Ignore = [ + "home" +] + +# Workspaces that are not enrolled in the device code preview do not advertise +# a device_authorization_endpoint in their OIDC discovery document. +[[Server]] +Pattern = "GET /oidc/.well-known/oauth-authorization-server" +Response.Body = ''' +{ + "authorization_endpoint": "https://workspace.test/oidc/v1/authorize", + "token_endpoint": "https://workspace.test/oidc/v1/token" +} +''' diff --git a/acceptance/cmd/auth/login/use-device-code-unified-host/out.test.toml b/acceptance/cmd/auth/login/use-device-code-unified-host/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-unified-host/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/use-device-code-unified-host/output.txt b/acceptance/cmd/auth/login/use-device-code-unified-host/output.txt new file mode 100644 index 00000000000..c5f77cc5597 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-unified-host/output.txt @@ -0,0 +1,5 @@ + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --use-device-code +Error: --use-device-code is only supported for workspace hosts + +>>> print_requests.py //oidc/v1/device/auth diff --git a/acceptance/cmd/auth/login/use-device-code-unified-host/script b/acceptance/cmd/auth/login/use-device-code-unified-host/script new file mode 100644 index 00000000000..51957a3f29b --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-unified-host/script @@ -0,0 +1,9 @@ +sethome "./home" +export DATABRICKS_CLI_DEVICE_CODE=true + +# An account-scoped discovery URL marks the host as a unified host. +export DATABRICKS_DISCOVERY_URL="$DATABRICKS_HOST/oidc/accounts/spog-acct-123/.well-known/oauth-authorization-server" + +# Unified hosts are rejected before the device authorization request. +trace musterr $CLI auth login --host $DATABRICKS_HOST --use-device-code +trace print_requests.py //oidc/v1/device/auth diff --git a/acceptance/cmd/auth/login/use-device-code-unified-host/test.toml b/acceptance/cmd/auth/login/use-device-code-unified-host/test.toml new file mode 100644 index 00000000000..84786fa60f0 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-unified-host/test.toml @@ -0,0 +1,4 @@ +Ignore = [ + "home" +] +RecordRequests = true diff --git a/acceptance/cmd/auth/login/use-device-code/out.databrickscfg b/acceptance/cmd/auth/login/use-device-code/out.databrickscfg new file mode 100644 index 00000000000..a8bc61758a1 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code/out.databrickscfg @@ -0,0 +1,16 @@ +; 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 + +[custom-scopes] +host = [DATABRICKS_URL] +workspace_id = [WORKSPACE_ID] +scopes = sql +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/login/use-device-code/out.test.toml b/acceptance/cmd/auth/login/use-device-code/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/use-device-code/output.txt b/acceptance/cmd/auth/login/use-device-code/output.txt new file mode 100644 index 00000000000..2641db212f9 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code/output.txt @@ -0,0 +1,166 @@ + +=== 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 + +=== Inputs device code login cannot honor are rejected + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --resource https://resource.test +Error: --use-device-code cannot be combined with --resource + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --skip-workspace +Error: --use-device-code cannot be combined with --skip-workspace + +>>> musterr [CLI] auth login --profile new-profile --use-device-code +Error: --use-device-code requires a workspace host. Pass it as an argument or with --host + +>>> musterr [CLI] auth login --profile scoped --use-device-code +Error: --use-device-code cannot be used with profile scoped because its login is scoped to resources. Log in without --use-device-code + +=== Account hosts are rejected, whether passed directly or from a profile + +>>> musterr [CLI] auth login --host https://accounts.cloud.databricks.test --profile test --use-device-code +Error: --use-device-code is only supported for workspace hosts + +>>> musterr [CLI] auth login --profile account --use-device-code +Error: --use-device-code is only supported for workspace hosts + +=== 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: 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"; + +=== Device code requests + +>>> print_requests.py //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/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-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" +} +{ + "method": "POST", + "path": "/oidc/v1/token", + "raw_body": "client_id=databricks-cli&confirmation_code=BBBBBBBB&device_code=device-code-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" +} +{ + "method": "POST", + "path": "/oidc/v1/token", + "raw_body": "client_id=databricks-cli&confirmation_code=CCCCCCCC&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=AAAAAAAA&device_code=device-code-3&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" +} +{ + "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-4&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" +} + +=== Custom scopes are requested at device authorization + +>>> [CLI] auth login --host [DATABRICKS_URL] --profile custom-scopes --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 custom-scopes was successfully saved + +>>> print_requests.py //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-5&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" +} diff --git a/acceptance/cmd/auth/login/use-device-code/script b/acceptance/cmd/auth/login/use-device-code/script new file mode 100644 index 00000000000..8c30d8bdffa --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code/script @@ -0,0 +1,63 @@ +sethome "./home" + +title "Device code login is disabled by default\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code + +export DATABRICKS_CLI_DEVICE_CODE=true + +title "Inputs device code login cannot honor are rejected\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --resource https://resource.test +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --skip-workspace +# 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, which device code login does not support. +trace musterr $CLI auth login --profile new-profile --use-device-code +cat > ./home/.databrickscfg << EOF +[scoped] +host = $DATABRICKS_HOST +auth_type = databricks-cli +resources = https://resource.test +EOF +trace musterr $CLI auth login --profile scoped --use-device-code +rm ./home/.databrickscfg + +# The profile has no account ID: the host is rejected before the CLI would +# prompt for one. +title "Account hosts are rejected, whether passed directly or from a profile\n" +trace musterr $CLI auth login --host https://accounts.cloud.databricks.test --profile test --use-device-code +cat > ./home/.databrickscfg << EOF +[account] +host = https://accounts.cloud.databricks.test +EOF +trace musterr $CLI auth login --profile account --use-device-code +rm ./home/.databrickscfg + +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 asks again without contacting the workspace. +# 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 +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 + +title "Device code requests\n" +trace print_requests.py //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 "Custom scopes are requested at device authorization\n" +printf "FTFA-QQKY\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile custom-scopes --use-device-code --scopes sql +trace print_requests.py //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/use-device-code/test.toml b/acceptance/cmd/auth/login/use-device-code/test.toml new file mode 100644 index 00000000000..84786fa60f0 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code/test.toml @@ -0,0 +1,4 @@ +Ignore = [ + "home" +] +RecordRequests = true diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go new file mode 100644 index 00000000000..8696b278d3f --- /dev/null +++ b/cmd/auth/login_device_code_test.go @@ -0,0 +1,225 @@ +package auth + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/auth/u2m" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/flags" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateDeviceCodeLogin(t *testing.T) { + tests := []struct { + name string + envVal string + setFlag string + flagVal string + wantErr string + }{ + { + name: "disabled by default", + wantErr: "--use-device-code is experimental. Set DATABRICKS_CLI_DEVICE_CODE=true to enable it", + }, + { + name: "explicitly disabled", + envVal: "false", + wantErr: "--use-device-code is experimental. Set DATABRICKS_CLI_DEVICE_CODE=true to enable it", + }, + { + name: "enabled", + envVal: "true", + }, + { + name: "resource is incompatible", + envVal: "1", + setFlag: "resource", + flagVal: "https://workspace.test/ai-gateway/mcp-services/system.ai.github", + wantErr: "--use-device-code cannot be combined with --resource", + }, + { + name: "skip-workspace is incompatible", + envVal: "1", + setFlag: "skip-workspace", + flagVal: "true", + wantErr: "--use-device-code cannot be combined with --skip-workspace", + }, + { + name: "skip-workspace set to its default", + envVal: "1", + setFlag: "skip-workspace", + flagVal: "false", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := env.Set(t.Context(), deviceCodeEnvVar, tt.envVal) + cmd := &cobra.Command{} + cmd.Flags().StringArray("resource", nil, "") + cmd.Flags().Bool("skip-workspace", false, "") + if tt.setFlag != "" { + require.NoError(t, cmd.Flags().Set(tt.setFlag, tt.flagVal)) + } + + err := validateDeviceCodeLogin(ctx, cmd) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestPromptForConfirmationCode(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr string + }{ + {name: "code", input: "FTFA-QQKY\n", want: "FTFA-QQKY"}, + {name: "code without newline", input: "FTFA-QQKY", want: "FTFA-QQKY"}, + {name: "blank line", input: " \nFTFA-QQKY\n", want: "FTFA-QQKY"}, + {name: "no input", input: "", wantErr: "reading confirmation code: EOF"}, + {name: "blank line without code", input: " \n", wantErr: "reading confirmation code: EOF"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stderr bytes.Buffer + ctx := cmdio.InContext(t.Context(), cmdio.NewIO(t.Context(), flags.OutputText, strings.NewReader(tt.input), &bytes.Buffer{}, &stderr, "", "")) + + code, err := promptForConfirmationCode(ctx, u2m.DeviceAuthorization{ + UserCode: "WDJBMJHT", + VerificationURI: "https://workspace.test/device/verification", + }, nil, 3) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, code) + } + assert.True(t, strings.HasPrefix(stderr.String(), strings.Join([]string{ + "Using a browser on another device, visit:", + "https://workspace.test/device/verification", + "", + "And enter the code:", + "WDJB-MJHT", + "", + "Once finished, enter the confirmation code provided in your browser.", + "Confirmation code: ", + }, "\n")), stderr.String()) + }) + } +} + +func TestPromptForConfirmationCode_Retry(t *testing.T) { + tests := []struct { + attemptsLeft int + want string + }{ + {attemptsLeft: 2, want: "wrong code. Enter the confirmation code again (2 attempts left).\nConfirmation code: "}, + {attemptsLeft: 1, want: "wrong code. Enter the confirmation code again (1 attempt left).\nConfirmation code: "}, + } + for _, tt := range tests { + var stderr bytes.Buffer + ctx := cmdio.InContext(t.Context(), cmdio.NewIO(t.Context(), flags.OutputText, strings.NewReader("FTFA-QQKY\n"), &bytes.Buffer{}, &stderr, "", "")) + + code, err := promptForConfirmationCode(ctx, u2m.DeviceAuthorization{ + UserCode: "WDJBMJHT", + VerificationURI: "https://workspace.test/device/verification", + }, errors.New("wrong code"), tt.attemptsLeft) + require.NoError(t, err) + assert.Equal(t, "FTFA-QQKY", code) + // The instructions are only shown before the first attempt. + assert.Equal(t, tt.want, stderr.String()) + } +} + +func TestPromptForConfirmationCode_Timeout(t *testing.T) { + // The pipe is never written to, so reading the code blocks until the + // context is done. + stdin, _ := io.Pipe() + defer stdin.Close() + ctx := cmdio.InContext(t.Context(), cmdio.NewIO(t.Context(), flags.OutputText, stdin, &bytes.Buffer{}, &bytes.Buffer{}, "", "")) + ctx, cancel := context.WithTimeout(ctx, 10*time.Millisecond) + defer cancel() + + _, err := promptForConfirmationCode(ctx, u2m.DeviceAuthorization{ + UserCode: "WDJBMJHT", + VerificationURI: "https://workspace.test/device/verification", + }, nil, 3) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.EqualError(t, err, "reading confirmation code: context deadline exceeded") +} + +func TestFormatCode(t *testing.T) { + assert.Equal(t, "WDJB-MJHT", formatCode("WDJBMJHT")) + assert.Equal(t, "ABC", formatCode("ABC")) +} + +func TestValidateDeviceCodeHost(t *testing.T) { + workspace := newDeviceCodeWorkspaceServer(t, true) + notEnabled := newDeviceCodeWorkspaceServer(t, false) + unified := newDiscoveryServer(t, map[string]any{ + "account_id": "acct-123", + "oidc_endpoint": "https://spog.test/oidc/accounts/acct-123", + }) + + tests := []struct { + name string + args auth.AuthArguments + wantErr error + }{ + {name: "workspace host", args: auth.AuthArguments{Host: workspace.URL}}, + {name: "workspace host with query params", args: auth.AuthArguments{Host: workspace.URL + "?o=12345"}}, + {name: "workspace without device code login", args: auth.AuthArguments{Host: notEnabled.URL}, wantErr: u2m.ErrDeviceCodeNotSupported}, + {name: "classic account host", args: auth.AuthArguments{Host: "https://accounts.cloud.databricks.test"}, wantErr: errDeviceCodeWorkspaceOnly}, + {name: "unified host", args: auth.AuthArguments{Host: unified.URL}, wantErr: errDeviceCodeWorkspaceOnly}, + {name: "unified host with query params", args: auth.AuthArguments{Host: unified.URL + "?o=12345"}, wantErr: errDeviceCodeWorkspaceOnly}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := tt.args + err := validateDeviceCodeHost(t.Context(), &args) + assert.ErrorIs(t, err, tt.wantErr) + // Discovery results must not leak into the arguments, where they + // would change the default profile name. + assert.Equal(t, tt.args, args) + }) + } +} + +// newDeviceCodeWorkspaceServer serves the host metadata and OIDC discovery +// documents of a workspace, optionally with device code login enabled. +func newDeviceCodeWorkspaceServer(t *testing.T, deviceCodeEnabled bool) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/.well-known/databricks-config": + _, _ = w.Write([]byte(`{"account_id": "acct-123", "workspace_id": "12345", "oidc_endpoint": "https://workspace.test/oidc"}`)) + case "/oidc/.well-known/oauth-authorization-server": + if deviceCodeEnabled { + _, _ = w.Write([]byte(`{"token_endpoint": "https://workspace.test/oidc/v1/token", "device_authorization_endpoint": "https://workspace.test/oidc/v1/device/auth"}`)) + } else { + _, _ = w.Write([]byte(`{"token_endpoint": "https://workspace.test/oidc/v1/token"}`)) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv +} diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go new file mode 100644 index 00000000000..40a289e7707 --- /dev/null +++ b/libs/auth/u2m/device_code_test.go @@ -0,0 +1,351 @@ +package u2m + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testDeviceCode = "device-code-123" + testUserCode = "WDJBMJHT" + testConfirmationCode = "FTFAQQKY" +) + +// deviceCodeEndpointSupplier serves workspace OIDC endpoints from a test +// server, optionally advertising the device authorization endpoint. +type deviceCodeEndpointSupplier struct { + MockOAuthEndpointSupplier + host string + deviceAuthAdvertise bool +} + +func (s deviceCodeEndpointSupplier) GetWorkspaceOAuthEndpoints(_ context.Context, _ string) (*OAuthAuthorizationServer, error) { + endpoints := &OAuthAuthorizationServer{ + AuthorizationEndpoint: s.host + "/oidc/v1/authorize", + TokenEndpoint: s.host + "/oidc/v1/token", + } + if s.deviceAuthAdvertise { + endpoints.DeviceAuthorizationEndpoint = s.host + "/oidc/v1/device/auth" + } + return endpoints, nil +} + +type fakeDeviceServer struct { + deviceAuthStatus int + deviceAuthBody string + tokenStatus int + tokenBody string + expiresIn int + + tokenRequests atomic.Int32 +} + +func (f *fakeDeviceServer) handler(t *testing.T) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /oidc/v1/device/auth", func(w http.ResponseWriter, r *http.Request) { + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "databricks-cli", r.PostForm.Get("client_id")) + assert.Equal(t, "offline_access all-apis", r.PostForm.Get("scope")) + w.Header().Set("Content-Type", "application/json") + if f.deviceAuthStatus != 0 { + w.WriteHeader(f.deviceAuthStatus) + _, _ = w.Write([]byte(f.deviceAuthBody)) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": testDeviceCode, + "user_code": testUserCode, + "verification_uri": "https://workspace.test/device/verification", + "expires_in": f.expiresIn, + "interval": 0, + }) + }) + mux.HandleFunc("POST /oidc/v1/token", func(w http.ResponseWriter, r *http.Request) { + f.tokenRequests.Add(1) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "urn:ietf:params:oauth:grant-type:device_code", r.PostForm.Get("grant_type")) + assert.Equal(t, "databricks-cli", r.PostForm.Get("client_id")) + assert.Equal(t, testDeviceCode, r.PostForm.Get("device_code")) + w.Header().Set("Content-Type", "application/json") + if f.tokenStatus != 0 { + w.WriteHeader(f.tokenStatus) + _, _ = w.Write([]byte(f.tokenBody)) + return + } + if r.PostForm.Get("confirmation_code") != testConfirmationCode { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_request", "error_description": "Invalid confirmation code"}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + }) + return mux +} + +func newDeviceCodeAuth(t *testing.T, f *fakeDeviceServer, advertise bool, opts ...PersistentAuthOption) *PersistentAuth { + srv := httptest.NewServer(f.handler(t)) + t.Cleanup(srv.Close) + arg, err := NewBasicWorkspaceOAuthArgument("https://workspace.test") + require.NoError(t, err) + p, err := NewPersistentAuth(t.Context(), append([]PersistentAuthOption{ + WithOAuthArgument(arg), + WithOAuthEndpointSupplier(deviceCodeEndpointSupplier{host: srv.URL, deviceAuthAdvertise: advertise}), + }, opts...)...) + require.NoError(t, err) + t.Cleanup(func() { p.Close() }) + return p +} + +func TestDeviceCodeChallenge(t *testing.T) { + f := &fakeDeviceServer{expiresIn: 600} + p := newDeviceCodeAuth(t, f, true) + + var got DeviceAuthorization + tok, err := p.DeviceCodeChallenge(func(_ context.Context, da DeviceAuthorization, _ error, _ int) (string, error) { + got = da + return " ftfa-qqky\t", nil + }) + require.NoError(t, err) + + assert.Equal(t, DeviceAuthorization{ + UserCode: testUserCode, + VerificationURI: "https://workspace.test/device/verification", + }, got) + assert.Equal(t, "access-token", tok.AccessToken) + assert.Equal(t, "refresh-token", tok.RefreshToken) + assert.WithinDuration(t, time.Now().Add(time.Hour), tok.Expiry, time.Minute) + assert.Equal(t, int32(1), f.tokenRequests.Load()) +} + +func TestDeviceCodeChallenge_ConfirmationAfterInitialExpiry(t *testing.T) { + // The server extends the device code's lifetime once the user approves + // the login, so a confirmation code entered after the initial expires_in + // must still be exchanged. + f := &fakeDeviceServer{expiresIn: 1} + p := newDeviceCodeAuth(t, f, true) + + tok, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + time.Sleep(1100 * time.Millisecond) + return testConfirmationCode, nil + }) + require.NoError(t, err) + assert.Equal(t, "access-token", tok.AccessToken) +} + +func TestDeviceCodeChallenge_Errors(t *testing.T) { + tests := []struct { + name string + server *fakeDeviceServer + wantErr string + }{ + { + name: "device authorization rejected", + server: &fakeDeviceServer{ + deviceAuthStatus: http.StatusBadRequest, + deviceAuthBody: `{"error": "unauthorized_client", "error_description": "Client is not allowed to use device code"}`, + }, + wantErr: "device authorization: Client is not allowed to use device code (error code: unauthorized_client)", + }, + { + name: "token request rejected", + server: &fakeDeviceServer{ + tokenStatus: http.StatusBadRequest, + tokenBody: `{"error": "invalid_client", "error_description": "Client is not allowed to use device code"}`, + }, + wantErr: "token exchange: Client is not allowed to use device code (error code: invalid_client)", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.server.expiresIn = 600 + p := newDeviceCodeAuth(t, tc.server, true) + _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + return testConfirmationCode, nil + }) + assert.EqualError(t, err, tc.wantErr) + }) + } +} + +func TestDeviceCodeChallenge_RetriesWrongConfirmationCode(t *testing.T) { + f := &fakeDeviceServer{expiresIn: 600} + p := newDeviceCodeAuth(t, f, true) + + var lastErrs []string + tok, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, lastErr error, _ int) (string, error) { + if lastErr == nil { + lastErrs = append(lastErrs, "") + return "AAAAAAAA", nil + } + lastErrs = append(lastErrs, lastErr.Error()) + return testConfirmationCode, nil + }) + require.NoError(t, err) + assert.Equal(t, "access-token", tok.AccessToken) + assert.Equal(t, []string{"", "Invalid confirmation code (error code: invalid_request)"}, lastErrs) + assert.Equal(t, int32(2), f.tokenRequests.Load()) +} + +func TestDeviceCodeChallenge_TooManyWrongConfirmationCodes(t *testing.T) { + f := &fakeDeviceServer{expiresIn: 600} + p := newDeviceCodeAuth(t, f, true) + + var attemptsLeft []int + _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, left int) (string, error) { + attemptsLeft = append(attemptsLeft, left) + return "AAAAAAAA", nil + }) + assert.EqualError(t, err, "Invalid confirmation code (error code: invalid_request). Start login from scratch") + assert.Equal(t, []int{3, 2, 1}, attemptsLeft) + assert.Equal(t, int32(maxConfirmationAttempts), f.tokenRequests.Load()) +} + +func TestDeviceCodeChallenge_ConfirmErrorOnRetry(t *testing.T) { + f := &fakeDeviceServer{expiresIn: 600} + p := newDeviceCodeAuth(t, f, true) + confirmErr := errors.New("no input") + + _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, lastErr error, _ int) (string, error) { + if lastErr != nil { + return "", confirmErr + } + return "AAAAAAAA", nil + }) + assert.ErrorIs(t, err, confirmErr) + assert.Equal(t, int32(1), f.tokenRequests.Load()) +} + +func TestDeviceCodeChallenge_DoesNotPoll(t *testing.T) { + // A plain HTTP client hands the error response to the CLI as is, rather + // than turning it into an HttpError. A pending login must fail right away + // instead of being polled until it is approved. + f := &fakeDeviceServer{ + expiresIn: 600, + tokenStatus: http.StatusBadRequest, + tokenBody: `{"error": "authorization_pending", "error_description": "Login is not approved yet"}`, + } + p := newDeviceCodeAuth(t, f, true, WithHttpClient(&http.Client{})) + + _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + return testConfirmationCode, nil + }) + assert.EqualError(t, err, "token exchange: Login is not approved yet (error code: authorization_pending)") + assert.Equal(t, int32(1), f.tokenRequests.Load()) +} + +func TestDeviceCodeChallenge_MissingAccessToken(t *testing.T) { + f := &fakeDeviceServer{ + expiresIn: 600, + tokenStatus: http.StatusOK, + tokenBody: `{"token_type": "Bearer"}`, + } + p := newDeviceCodeAuth(t, f, true) + + _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + return testConfirmationCode, nil + }) + assert.EqualError(t, err, "token exchange: server response missing access_token") +} + +func TestDeviceCodeChallenge_ConfirmError(t *testing.T) { + f := &fakeDeviceServer{expiresIn: 600} + p := newDeviceCodeAuth(t, f, true) + confirmErr := errors.New("no input") + + _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + return "", confirmErr + }) + assert.ErrorIs(t, err, confirmErr) + assert.Equal(t, int32(0), f.tokenRequests.Load()) +} + +func TestDeviceCodeChallenge_NotAdvertised(t *testing.T) { + p := newDeviceCodeAuth(t, &fakeDeviceServer{}, false) + + _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + t.Fatal("confirm must not be called") + return "", nil + }) + assert.ErrorIs(t, err, ErrDeviceCodeNotSupported) +} + +func TestDeviceCodeChallenge_AccountHost(t *testing.T) { + arg, err := NewBasicAccountOAuthArgument("https://accounts.cloud.databricks.test", "xyz") + require.NoError(t, err) + p, err := NewPersistentAuth(t.Context(), + WithOAuthArgument(arg), + WithOAuthEndpointSupplier(MockOAuthEndpointSupplier{}), + ) + require.NoError(t, err) + defer p.Close() + + _, err = p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + t.Fatal("confirm must not be called") + return "", nil + }) + assert.EqualError(t, err, "device code login is only supported for workspace hosts") +} + +func TestNormalizeCode(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"FTFAQQKY", "FTFAQQKY"}, + {"FTFA-QQKY", "FTFAQQKY"}, + {"ftfa-qqky", "FTFAQQKY"}, + {" FTFA QQKY \n", "FTFAQQKY"}, + } + for _, tc := range tests { + assert.Equal(t, tc.want, normalizeCode(tc.in), tc.in) + } +} + +func TestCheckDeviceCodeSupported(t *testing.T) { + tests := []struct { + name string + body string + wantErr string + }{ + { + name: "advertised", + body: `{"token_endpoint": "https://workspace.test/oidc/v1/token", "device_authorization_endpoint": "https://workspace.test/oidc/v1/device/auth"}`, + }, + { + name: "not advertised", + body: `{"token_endpoint": "https://workspace.test/oidc/v1/token"}`, + wantErr: "device code login is not enabled for this workspace", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oidc/.well-known/oauth-authorization-server", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + + err := CheckDeviceCodeSupported(t.Context(), srv.URL) + if tc.wantErr != "" { + assert.EqualError(t, err, tc.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/libs/cmdio/ask_test.go b/libs/cmdio/ask_test.go index 75fc520f8d5..75fc64d7c54 100644 --- a/libs/cmdio/ask_test.go +++ b/libs/cmdio/ask_test.go @@ -1,6 +1,7 @@ package cmdio import ( + "context" "errors" "io" "strings" @@ -177,3 +178,29 @@ func TestAskYesOrNo(t *testing.T) { }) } } + +func TestAskCancellable(t *testing.T) { + ctx := t.Context() + ctx = InContext(ctx, NewIO(ctx, flags.OutputText, strings.NewReader("answer\n"), io.Discard, io.Discard, "", "")) + got, err := AskCancellable(ctx, "Question", "") + require.NoError(t, err) + assert.Equal(t, "answer", got) +} + +func TestAskCancellable_ReadError(t *testing.T) { + ctx := t.Context() + ctx = InContext(ctx, NewIO(ctx, flags.OutputText, strings.NewReader(""), io.Discard, io.Discard, "", "")) + _, err := AskCancellable(ctx, "Question", "") + assert.ErrorIs(t, err, io.EOF) +} + +func TestAskCancellable_Cancelled(t *testing.T) { + // The pipe is never written to, so the read blocks until ctx is done. + stdin, _ := io.Pipe() + defer stdin.Close() + ctx, cancel := context.WithCancel(t.Context()) + ctx = InContext(ctx, NewIO(ctx, flags.OutputText, stdin, io.Discard, io.Discard, "", "")) + cancel() + _, err := AskCancellable(ctx, "Question", "") + assert.ErrorIs(t, err, context.Canceled) +} diff --git a/libs/testserver/fake_oidc.go b/libs/testserver/fake_oidc.go index db6a682c9bd..492b1967056 100644 --- a/libs/testserver/fake_oidc.go +++ b/libs/testserver/fake_oidc.go @@ -3,18 +3,112 @@ package testserver import ( "net/http" "net/url" + "slices" + "strconv" + "strings" + "sync" +) + +const ( + deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" + + // Device code login is approved on another device, which acceptance tests + // cannot drive, so every device authorization is treated as approved with + // a fixed confirmation code that tests can type into the CLI. + fakeDeviceUserCode = "WDJBMJHT" + fakeDeviceConfirmationCode = "FTFAQQKY" + + // Like the real server, only allowlisted OAuth clients can use device code + // login. The fake allows only the CLI's default client. + fakeDeviceClientID = "databricks-cli" ) // FakeOidc holds OAuth state for acceptance tests. type FakeOidc struct { url string + + mu sync.Mutex + + // deviceCodes maps device codes that have not been exchanged yet to + // whether offline_access was requested for them. + deviceCodes map[string]bool + lastDeviceCode int } func (s *FakeOidc) OidcEndpoints() Response { return Response{ Body: map[string]string{ - "authorization_endpoint": s.url + "/oidc/v1/authorize", - "token_endpoint": s.url + "/oidc/v1/token", + "authorization_endpoint": s.url + "/oidc/v1/authorize", + "token_endpoint": s.url + "/oidc/v1/token", + "device_authorization_endpoint": s.url + "/oidc/v1/device/auth", + }, + } +} + +// OidcDeviceAuth implements the RFC 8628 device authorization endpoint. +func (s *FakeOidc) OidcDeviceAuth(req Request) Response { + form, err := url.ParseQuery(string(req.Body)) + if err != nil || form.Get("client_id") == "" { + return oauthErrorResponse("invalid_request", "Bad Request") + } + if form.Get("client_id") != fakeDeviceClientID { + return oauthErrorResponse("unauthorized_client", "Client is not allowed to use device code login") + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.deviceCodes == nil { + s.deviceCodes = map[string]bool{} + } + s.lastDeviceCode++ + deviceCode := "device-code-" + strconv.Itoa(s.lastDeviceCode) + s.deviceCodes[deviceCode] = slices.Contains(strings.Fields(form.Get("scope")), "offline_access") + + return Response{ + Body: map[string]any{ + "device_code": deviceCode, + "user_code": fakeDeviceUserCode, + "verification_uri": s.url + "/device/verification", + "expires_in": 600, + "interval": 0, + }, + } +} + +// deviceCodeToken exchanges a device code and confirmation code for a token. +// Like the real server, a wrong confirmation code leaves the device code +// valid, and a successful exchange consumes it. +func (s *FakeOidc) deviceCodeToken(form url.Values) Response { + s.mu.Lock() + defer s.mu.Unlock() + deviceCode := form.Get("device_code") + offlineAccess, ok := s.deviceCodes[deviceCode] + if !ok { + return oauthErrorResponse("invalid_request", "Invalid device code") + } + if form.Get("confirmation_code") != fakeDeviceConfirmationCode { + return oauthErrorResponse("invalid_request", "Invalid confirmation code") + } + delete(s.deviceCodes, deviceCode) + + body := map[string]any{ + "access_token": "oauth-token", + "expires_in": 3600, + "scope": "all-apis", + "token_type": "Bearer", + } + if offlineAccess { + body["refresh_token"] = "oauth-refresh-token" + } + return Response{Body: body} +} + +func oauthErrorResponse(code, description string) Response { + return Response{ + StatusCode: http.StatusBadRequest, + Body: map[string]string{ + "error": code, + "error_description": description, }, } } @@ -49,6 +143,10 @@ func (s *FakeOidc) OidcAuthorize(req Request) Response { } func (s *FakeOidc) OidcToken(req Request) Response { + form, err := url.ParseQuery(string(req.Body)) + if err == nil && form.Get("grant_type") == deviceCodeGrantType { + return s.deviceCodeToken(form) + } return Response{ Body: map[string]string{ "access_token": "oauth-token", diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index e1a14b9800b..8ea561ca2ba 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -551,6 +551,10 @@ func AddDefaultHandlers(server *Server) { return server.fakeOidc.OidcToken(req) }) + server.Handle("POST", "/oidc/v1/device/auth", func(req Request) any { + return server.fakeOidc.OidcDeviceAuth(req) + }) + server.Handle("POST", "/telemetry-ext", func(_ Request) any { return map[string]any{ "errors": []string{}, From 55c98996e16c44a832282408b0a1825a2d065a25 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Mon, 19 Oct 2026 13:54:07 +0000 Subject: [PATCH 03/24] Only retry device code confirmation on HTTP 400 invalid_request The server answers a rate-limited device code token request with invalid_request too, but with HTTP 429. Entering the confirmation code again cannot fix that, so end the login for any error other than HTTP 400 with invalid_request. Co-authored-by: Isaac --- cmd/auth/README.md | 4 ++-- libs/auth/u2m/device_code.go | 27 +++++++++++++++------------ libs/auth/u2m/device_code_test.go | 8 ++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/cmd/auth/README.md b/cmd/auth/README.md index 436c88d78eb..db130de2272 100644 --- a/cmd/auth/README.md +++ b/cmd/auth/README.md @@ -93,8 +93,8 @@ Notes on the server behavior the CLI relies on: - The token request carries the confirmation code in the `confirmation_code` form parameter. A wrong confirmation code does not consume the device code; a successful exchange does. The server answers a wrong confirmation code - with `invalid_request`, and the CLI lets the user enter it up to 3 times in - total. + with HTTP 400 and `invalid_request`, and the CLI lets the user enter it up + to 3 times in total. Any other error ends the login. - Approving the login in the browser extends the device code's lifetime, so the CLI does not enforce the `expires_in` from the device authorization response. diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 0661bd4bdbc..7024155f38f 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -106,9 +106,11 @@ func (a *PersistentAuth) DeviceCodeChallenge(confirm ConfirmFunc) (*oauth2.Token return t, nil } lastErr = oauthError(err) - // The server answers a wrong confirmation code with invalid_request. - // Any other error cannot be fixed by entering the code again. - if errCode, _ := oauthErrorResponse(err); errCode != "invalid_request" { + // 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. + if status, errCode, _ := oauthErrorResponse(err); status != http.StatusBadRequest || errCode != "invalid_request" { return nil, fmt.Errorf("token exchange: %w", lastErr) } } @@ -198,17 +200,18 @@ func normalizeCode(code string) string { // oauthError extracts the RFC 6749 §5.2 error response from err so that users // see the server's explanation instead of a raw HTTP response body. func oauthError(err error) error { - code, description := oauthErrorResponse(err) + _, code, description := oauthErrorResponse(err) if code == "" { return err } return fmt.Errorf("%s (error code: %s)", description, code) } -// oauthErrorResponse returns the error code and description of the RFC 6749 -// §5.2 error response carried by err. The code is empty if err does not carry -// one. -func oauthErrorResponse(err error) (code, description string) { +// oauthErrorResponse returns the HTTP status code, and the error code and +// description of the RFC 6749 §5.2 error response carried by err. The status +// is 0 if err does not carry an HTTP response, and the code is empty if err +// does not carry an error response. +func oauthErrorResponse(err error) (status int, code, description string) { // The default RoundTripper of httpclient.ApiClient returns an error for // non-2xx responses, so the oauth2 library never parses the body itself. if httpErr, ok := errors.AsType[*httpclient.HttpError](err); ok { @@ -217,12 +220,12 @@ func oauthErrorResponse(err error) (code, description string) { ErrorDescription string `json:"error_description"` } if json.Unmarshal([]byte(httpErr.Message), &resp) != nil { - return "", "" + return httpErr.StatusCode, "", "" } - return resp.Error, resp.ErrorDescription + return httpErr.StatusCode, resp.Error, resp.ErrorDescription } if retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err); ok { - return retrieveErr.ErrorCode, retrieveErr.ErrorDescription + return retrieveErr.Response.StatusCode, retrieveErr.ErrorCode, retrieveErr.ErrorDescription } - return "", "" + return 0, "", "" } diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 40a289e7707..0b5441d6da2 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -168,6 +168,14 @@ func TestDeviceCodeChallenge_Errors(t *testing.T) { }, wantErr: "token exchange: Client is not allowed to use device code (error code: invalid_client)", }, + { + name: "token request rate limited", + server: &fakeDeviceServer{ + tokenStatus: http.StatusTooManyRequests, + tokenBody: `{"error": "invalid_request", "error_description": "Device grant rate limit exceeded"}`, + }, + wantErr: "token exchange: Device grant rate limit exceeded (error code: invalid_request)", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { From b4ff45557ce680fd21550372c6fcb69ccf02561f Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Thu, 22 Oct 2026 10:49:15 +0000 Subject: [PATCH 04/24] Describe device code login as experimental instead of a proof of concept Co-authored-by: Isaac --- cmd/auth/README.md | 2 +- cmd/auth/login.go | 4 ++-- docs/device-code-login.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/auth/README.md b/cmd/auth/README.md index db130de2272..1de89a535c5 100644 --- a/cmd/auth/README.md +++ b/cmd/auth/README.md @@ -53,7 +53,7 @@ sequenceDiagram # Device code login (experimental) `databricks auth login --use-device-code` logs in from a machine without a browser. -It is a proof of concept: the flag is hidden and requires +It is experimental: the flag is hidden and requires `DATABRICKS_CLI_DEVICE_CODE=true`. Only workspace hosts are supported. See [docs/device-code-login.md](../../docs/device-code-login.md) for the user guide. diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 7d0b6c11e43..34b41a8b2b3 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -50,7 +50,7 @@ const ( // development against non-production environments. discoveryHostEnvVar = "DATABRICKS_DISCOVERY_HOST" // deviceCodeEnvVar enables the experimental device code login flow - // (--use-device-code) while it is a proof of concept. + // (--use-device-code) while it is experimental. deviceCodeEnvVar = "DATABRICKS_CLI_DEVICE_CODE" ) @@ -171,7 +171,7 @@ a new profile is created. "RFC 8707 resource indicator to scope the login to (repeatable). Requires --host.") cmd.Flags().BoolVar(&useDeviceCode, "use-device-code", false, "Log in with a device code, using a browser on another device (experimental)") - // Hidden while device code login is an experimental proof of concept. + // Hidden while device code login is experimental. cmd.Flags().MarkHidden("use-device-code") cmd.PreRunE = profileHostConflictCheck diff --git a/docs/device-code-login.md b/docs/device-code-login.md index 70e749c1291..50bb63c2163 100644 --- a/docs/device-code-login.md +++ b/docs/device-code-login.md @@ -6,8 +6,8 @@ approve the login in a browser on another device (for example your laptop or phone), and type the confirmation code it shows you back into the CLI. > [!WARNING] -> Device code login is an experimental proof of concept. It is disabled by -> default, may change without notice, and only supports workspace hosts. +> Device code login is experimental. It is disabled by default, may change +> without notice, and only supports workspace hosts. > Account hosts and unified (SPOG) hosts are not supported yet. ## Prerequisites From 4fea0111306856a87de0f71e68696f4e11c34e9f Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Thu, 22 Oct 2026 10:51:23 +0000 Subject: [PATCH 05/24] Rename needsAccountIDPrompt to isAccountOrUnifiedHost The device code host check uses it to reject account and unified hosts, not to decide whether to prompt. Keep needsAccountIDPrompt as a local variable where setHostAndAccountId decides to prompt. Co-authored-by: Isaac --- cmd/auth/login.go | 7 ++++--- cmd/auth/login_device_code.go | 4 ++-- cmd/auth/login_test.go | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 34b41a8b2b3..f8a924c1b5d 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -598,7 +598,8 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, // are logged as warnings and never block login. runHostDiscovery(ctx, authArguments) - if needsAccountIDPrompt(authArguments.Host, authArguments.DiscoveryURL) && authArguments.AccountID == "" { + needsAccountIDPrompt := isAccountOrUnifiedHost(authArguments.Host, authArguments.DiscoveryURL) && authArguments.AccountID == "" + if needsAccountIDPrompt { if existingProfile != nil && existingProfile.AccountID != "" { authArguments.AccountID = existingProfile.AccountID } else { @@ -613,10 +614,10 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, return nil } -// needsAccountIDPrompt reports whether the target host requires an account ID +// isAccountOrUnifiedHost reports whether the target host requires an account ID // for OAuth URL construction. True for classic account hosts (accounts.*) and // for unified hosts detected via account-scoped DiscoveryURL. -func needsAccountIDPrompt(host, discoveryURL string) bool { +func isAccountOrUnifiedHost(host, discoveryURL string) bool { canonicalHost := (&config.Config{Host: host}).CanonicalHostName() if auth.IsClassicAccountHost(canonicalHost) { return true diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index d7df95c3c41..54fb6523e86 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -37,12 +37,12 @@ func validateDeviceCodeHost(ctx context.Context, authArguments *auth.AuthArgumen // workspace hosts, which must not change the default profile name. probe := *authArguments probe.Host = auth.ExtractHostQueryParams(probe.Host).Host - if needsAccountIDPrompt(probe.Host, probe.DiscoveryURL) { + if isAccountOrUnifiedHost(probe.Host, probe.DiscoveryURL) { return errDeviceCodeWorkspaceOnly } if probe.DiscoveryURL == "" { runHostDiscovery(ctx, &probe) - if needsAccountIDPrompt(probe.Host, probe.DiscoveryURL) { + if isAccountOrUnifiedHost(probe.Host, probe.DiscoveryURL) { return errDeviceCodeWorkspaceOnly } } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 026a002a158..1c9d5b825d0 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -424,7 +424,7 @@ func TestShouldUseDiscovery(t *testing.T) { } } -func TestNeedsAccountIDPrompt(t *testing.T) { +func TestIsAccountOrUnifiedHost(t *testing.T) { cases := []struct { name string host string @@ -441,7 +441,7 @@ func TestNeedsAccountIDPrompt(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := needsAccountIDPrompt(tc.host, tc.discoveryURL) + got := isAccountOrUnifiedHost(tc.host, tc.discoveryURL) assert.Equal(t, tc.want, got) }) } From eb7444465c6def8a59639282efb9a16e77cb48fd Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Thu, 22 Oct 2026 10:54:57 +0000 Subject: [PATCH 06/24] Share the device code support check with DeviceCodeChallenge CheckDeviceCodeSupported fetched the OIDC endpoints with its own client and repeated the check DeviceCodeChallenge makes. Make it a PersistentAuth method that runs the same deviceCodeConfig as DeviceCodeChallenge, so the up-front check and the login cannot disagree. Co-authored-by: Isaac --- cmd/auth/login_device_code.go | 11 ++++++++- libs/auth/u2m/device_code.go | 40 +++++++++++++++++-------------- libs/auth/u2m/device_code_test.go | 32 ++++++------------------- 3 files changed, 39 insertions(+), 44 deletions(-) diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index 54fb6523e86..fd6dc970fa3 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -46,7 +46,16 @@ func validateDeviceCodeHost(ctx context.Context, authArguments *auth.AuthArgumen return errDeviceCodeWorkspaceOnly } } - return u2m.CheckDeviceCodeSupported(ctx, (&config.Config{Host: probe.Host}).CanonicalHostName()) + arg, err := u2m.NewBasicWorkspaceOAuthArgument((&config.Config{Host: probe.Host}).CanonicalHostName()) + if err != nil { + return err + } + persistentAuth, err := u2m.NewPersistentAuth(ctx, u2m.WithOAuthArgument(arg)) + if err != nil { + return err + } + defer persistentAuth.Close() + return persistentAuth.CheckDeviceCodeSupported() } // validateDeviceCodeLogin returns an error if device code login is not enabled diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 7024155f38f..5f86c5a9a94 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -50,19 +50,29 @@ const maxConfirmationAttempts = 3 // the user can still enter, including this one. type ConfirmFunc func(ctx context.Context, da DeviceAuthorization, lastErr error, attemptsLeft int) (string, error) -// CheckDeviceCodeSupported returns ErrDeviceCodeNotSupported if the workspace -// does not advertise a device authorization endpoint in its OIDC discovery -// document. Callers use it to fail before prompting the user for anything. -func CheckDeviceCodeSupported(ctx context.Context, workspaceHost string) error { - supplier := &BasicOAuthEndpointSupplier{Client: httpclient.NewApiClient(httpclient.ClientConfig{})} - endpoints, err := supplier.GetWorkspaceOAuthEndpoints(ctx, workspaceHost) +// CheckDeviceCodeSupported returns the error DeviceCodeChallenge would fail +// with before contacting the device authorization endpoint, such as +// ErrDeviceCodeNotSupported. Callers use it to fail before prompting the user +// for anything. +func (a *PersistentAuth) CheckDeviceCodeSupported() error { + _, err := a.deviceCodeConfig() + return err +} + +// deviceCodeConfig returns the OAuth config for device code login, or an +// error if the host does not support it. +func (a *PersistentAuth) deviceCodeConfig() (*oauth2.Config, error) { + if _, ok := a.oAuthArgument.(WorkspaceOAuthArgument); !ok { + return nil, errors.New("device code login is only supported for workspace hosts") + } + cfg, err := a.oauth2Config() if err != nil { - return fmt.Errorf("fetching OAuth endpoints: %w", err) + return nil, fmt.Errorf("fetching oauth config: %w", err) } - if endpoints.DeviceAuthorizationEndpoint == "" { - return ErrDeviceCodeNotSupported + if cfg.Endpoint.DeviceAuthURL == "" { + return nil, ErrDeviceCodeNotSupported } - return nil + return cfg, nil } // DeviceCodeChallenge logs in using the Databricks variant of the RFC 8628 @@ -73,15 +83,9 @@ func CheckDeviceCodeSupported(ctx context.Context, workspaceHost string) error { // it up to maxConfirmationAttempts times. Only workspace hosts are supported. // The caller is responsible for storing the returned token. func (a *PersistentAuth) DeviceCodeChallenge(confirm ConfirmFunc) (*oauth2.Token, error) { - if _, ok := a.oAuthArgument.(WorkspaceOAuthArgument); !ok { - return nil, errors.New("device code login is only supported for workspace hosts") - } - cfg, err := a.oauth2Config() + cfg, err := a.deviceCodeConfig() if err != nil { - return nil, fmt.Errorf("fetching oauth config: %w", err) - } - if cfg.Endpoint.DeviceAuthURL == "" { - return nil, ErrDeviceCodeNotSupported + return nil, err } ctx := a.setOAuthContext(a.ctx) diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 0b5441d6da2..784f1c7895e 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -325,35 +325,17 @@ func TestNormalizeCode(t *testing.T) { func TestCheckDeviceCodeSupported(t *testing.T) { tests := []struct { - name string - body string - wantErr string + name string + advertise bool + wantErr error }{ - { - name: "advertised", - body: `{"token_endpoint": "https://workspace.test/oidc/v1/token", "device_authorization_endpoint": "https://workspace.test/oidc/v1/device/auth"}`, - }, - { - name: "not advertised", - body: `{"token_endpoint": "https://workspace.test/oidc/v1/token"}`, - wantErr: "device code login is not enabled for this workspace", - }, + {name: "advertised", advertise: true}, + {name: "not advertised", wantErr: ErrDeviceCodeNotSupported}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/oidc/.well-known/oauth-authorization-server", r.URL.Path) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(tc.body)) - })) - defer srv.Close() - - err := CheckDeviceCodeSupported(t.Context(), srv.URL) - if tc.wantErr != "" { - assert.EqualError(t, err, tc.wantErr) - } else { - assert.NoError(t, err) - } + p := newDeviceCodeAuth(t, &fakeDeviceServer{}, tc.advertise) + assert.ErrorIs(t, p.CheckDeviceCodeSupported(), tc.wantErr) }) } } From cbd3d8f3f792f416014e63f8c3dade5ba3dfd96d Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Thu, 22 Oct 2026 10:57:32 +0000 Subject: [PATCH 07/24] Split the device code login acceptance test by user journey Group the cases so that a failing test name points at the broken step: preconditions, the successful login (with a host, from a saved profile and with --scopes), unsupported hosts, an unallowed client, and missing or wrong confirmation codes. Only the nominal test prints the device code requests, because the requested scope is what it checks; the unsupported host test checks that no device authorization request is made. Co-authored-by: Isaac --- acceptance/cmd/auth/login/timeout/output.txt | 10 ++ acceptance/cmd/auth/login/timeout/script | 5 + .../out.databrickscfg | 2 +- .../out.test.toml | 0 .../login/use-device-code-nominal/output.txt | 56 ++++++ .../auth/login/use-device-code-nominal/script | 21 +++ .../test.toml | 0 .../use-device-code-not-enabled/output.txt | 6 - .../login/use-device-code-not-enabled/script | 12 -- .../home/.databrickscfg.tmpl | 4 + .../out.test.toml | 0 .../use-device-code-precondition/output.txt | 16 ++ .../login/use-device-code-precondition/script | 14 ++ .../test.toml | 1 - .../use-device-code-unified-host/output.txt | 5 - .../login/use-device-code-unified-host/script | 9 - .../home/.databrickscfg.tmpl | 6 + .../out.test.toml | 0 .../output.txt | 30 ++++ .../use-device-code-unsupported-host/script | 28 +++ .../test.toml | 1 + .../use-device-code-wrong-code/out.test.toml | 1 + .../use-device-code-wrong-code/output.txt | 39 ++++ .../login/use-device-code-wrong-code/script | 14 ++ .../use-device-code-wrong-code/test.toml | 3 + .../cmd/auth/login/use-device-code/output.txt | 166 ------------------ .../cmd/auth/login/use-device-code/script | 63 ------- 27 files changed, 249 insertions(+), 263 deletions(-) rename acceptance/cmd/auth/login/{use-device-code => use-device-code-nominal}/out.databrickscfg (95%) rename acceptance/cmd/auth/login/{use-device-code-not-enabled => use-device-code-nominal}/out.test.toml (100%) create mode 100644 acceptance/cmd/auth/login/use-device-code-nominal/output.txt create mode 100644 acceptance/cmd/auth/login/use-device-code-nominal/script rename acceptance/cmd/auth/login/{use-device-code-unified-host => use-device-code-nominal}/test.toml (100%) delete mode 100644 acceptance/cmd/auth/login/use-device-code-not-enabled/output.txt delete mode 100644 acceptance/cmd/auth/login/use-device-code-not-enabled/script create mode 100644 acceptance/cmd/auth/login/use-device-code-precondition/home/.databrickscfg.tmpl rename acceptance/cmd/auth/login/{use-device-code-unified-host => use-device-code-precondition}/out.test.toml (100%) create mode 100644 acceptance/cmd/auth/login/use-device-code-precondition/output.txt create mode 100644 acceptance/cmd/auth/login/use-device-code-precondition/script rename acceptance/cmd/auth/login/{use-device-code => use-device-code-precondition}/test.toml (52%) delete mode 100644 acceptance/cmd/auth/login/use-device-code-unified-host/output.txt delete mode 100644 acceptance/cmd/auth/login/use-device-code-unified-host/script create mode 100644 acceptance/cmd/auth/login/use-device-code-unsupported-host/home/.databrickscfg.tmpl rename acceptance/cmd/auth/login/{use-device-code => use-device-code-unsupported-host}/out.test.toml (100%) create mode 100644 acceptance/cmd/auth/login/use-device-code-unsupported-host/output.txt create mode 100644 acceptance/cmd/auth/login/use-device-code-unsupported-host/script rename acceptance/cmd/auth/login/{use-device-code-not-enabled => use-device-code-unsupported-host}/test.toml (94%) create mode 100644 acceptance/cmd/auth/login/use-device-code-wrong-code/out.test.toml create mode 100644 acceptance/cmd/auth/login/use-device-code-wrong-code/output.txt create mode 100644 acceptance/cmd/auth/login/use-device-code-wrong-code/script create mode 100644 acceptance/cmd/auth/login/use-device-code-wrong-code/test.toml delete mode 100644 acceptance/cmd/auth/login/use-device-code/output.txt delete mode 100644 acceptance/cmd/auth/login/use-device-code/script diff --git a/acceptance/cmd/auth/login/timeout/output.txt b/acceptance/cmd/auth/login/timeout/output.txt index 07b20caaa9b..e0a45999bbd 100644 --- a/acceptance/cmd/auth/login/timeout/output.txt +++ b/acceptance/cmd/auth/login/timeout/output.txt @@ -1,3 +1,13 @@ >>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --timeout 1s Error: authorize: context deadline exceeded + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --timeout 1s +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: context deadline exceeded diff --git a/acceptance/cmd/auth/login/timeout/script b/acceptance/cmd/auth/login/timeout/script index bbe57b6c03c..0948a21caf7 100644 --- a/acceptance/cmd/auth/login/timeout/script +++ b/acceptance/cmd/auth/login/timeout/script @@ -5,3 +5,8 @@ sethome "./home" export BROWSER="echo_browser.py" trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --timeout 1s > /dev/null + +# Keep stdin open without sending a confirmation code, so only --timeout ends +# the device code login. The sleep must outlast the timeout, otherwise the +# login fails with EOF instead. +sleep 5 | DATABRICKS_CLI_DEVICE_CODE=true trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --timeout 1s diff --git a/acceptance/cmd/auth/login/use-device-code/out.databrickscfg b/acceptance/cmd/auth/login/use-device-code-nominal/out.databrickscfg similarity index 95% rename from acceptance/cmd/auth/login/use-device-code/out.databrickscfg rename to acceptance/cmd/auth/login/use-device-code-nominal/out.databrickscfg index a8bc61758a1..02b3737c466 100644 --- a/acceptance/cmd/auth/login/use-device-code/out.databrickscfg +++ b/acceptance/cmd/auth/login/use-device-code-nominal/out.databrickscfg @@ -9,7 +9,7 @@ auth_type = databricks-cli [__settings__] default_profile = test -[custom-scopes] +[scoped] host = [DATABRICKS_URL] workspace_id = [WORKSPACE_ID] scopes = sql diff --git a/acceptance/cmd/auth/login/use-device-code-not-enabled/out.test.toml b/acceptance/cmd/auth/login/use-device-code-nominal/out.test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-not-enabled/out.test.toml rename to acceptance/cmd/auth/login/use-device-code-nominal/out.test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-nominal/output.txt b/acceptance/cmd/auth/login/use-device-code-nominal/output.txt new file mode 100644 index 00000000000..2216533b304 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-nominal/output.txt @@ -0,0 +1,56 @@ + +=== 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/use-device-code-nominal/script b/acceptance/cmd/auth/login/use-device-code-nominal/script new file mode 100644 index 00000000000..145e6d4fced --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-nominal/script @@ -0,0 +1,21 @@ +sethome "./home" +export DATABRICKS_CLI_DEVICE_CODE=true + +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/use-device-code-unified-host/test.toml b/acceptance/cmd/auth/login/use-device-code-nominal/test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-unified-host/test.toml rename to acceptance/cmd/auth/login/use-device-code-nominal/test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-not-enabled/output.txt b/acceptance/cmd/auth/login/use-device-code-not-enabled/output.txt deleted file mode 100644 index eb541e82566..00000000000 --- a/acceptance/cmd/auth/login/use-device-code-not-enabled/output.txt +++ /dev/null @@ -1,6 +0,0 @@ - ->>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code -Error: device code login is not enabled for this workspace - ->>> musterr [CLI] auth login --profile ws --use-device-code -Error: device code login is not enabled for this workspace diff --git a/acceptance/cmd/auth/login/use-device-code-not-enabled/script b/acceptance/cmd/auth/login/use-device-code-not-enabled/script deleted file mode 100644 index 087b878e2d1..00000000000 --- a/acceptance/cmd/auth/login/use-device-code-not-enabled/script +++ /dev/null @@ -1,12 +0,0 @@ -sethome "./home" -export DATABRICKS_CLI_DEVICE_CODE=true - -trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code - -# The check also applies to a host that comes from a profile. -cat > ./home/.databrickscfg << EOF -[ws] -host = $DATABRICKS_HOST -auth_type = databricks-cli -EOF -trace musterr $CLI auth login --profile ws --use-device-code diff --git a/acceptance/cmd/auth/login/use-device-code-precondition/home/.databrickscfg.tmpl b/acceptance/cmd/auth/login/use-device-code-precondition/home/.databrickscfg.tmpl new file mode 100644 index 00000000000..328fc4d2cc4 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-precondition/home/.databrickscfg.tmpl @@ -0,0 +1,4 @@ +[scoped] +host = $DATABRICKS_HOST +auth_type = databricks-cli +resources = https://resource.test diff --git a/acceptance/cmd/auth/login/use-device-code-unified-host/out.test.toml b/acceptance/cmd/auth/login/use-device-code-precondition/out.test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-unified-host/out.test.toml rename to acceptance/cmd/auth/login/use-device-code-precondition/out.test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-precondition/output.txt b/acceptance/cmd/auth/login/use-device-code-precondition/output.txt new file mode 100644 index 00000000000..0b34cd722e0 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-precondition/output.txt @@ -0,0 +1,16 @@ + +=== 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 + +=== Inputs device code login cannot honor are rejected + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --resource https://resource.test +Error: --use-device-code cannot be combined with --resource + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --skip-workspace +Error: --use-device-code cannot be combined with --skip-workspace + +>>> musterr [CLI] auth login --profile scoped --use-device-code +Error: --use-device-code cannot be used with profile scoped because its login is scoped to resources. Log in without --use-device-code diff --git a/acceptance/cmd/auth/login/use-device-code-precondition/script b/acceptance/cmd/auth/login/use-device-code-precondition/script new file mode 100644 index 00000000000..90dd16f9cc5 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-precondition/script @@ -0,0 +1,14 @@ +sethome "./home" + +title "Device code login is disabled by default\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code + +export DATABRICKS_CLI_DEVICE_CODE=true + +title "Inputs device code login cannot honor are rejected\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --resource https://resource.test +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --skip-workspace + +# A profile scoped to resources is rejected like --resource. +envsubst < "./home/.databrickscfg.tmpl" > "./home/.databrickscfg" +trace musterr $CLI auth login --profile scoped --use-device-code diff --git a/acceptance/cmd/auth/login/use-device-code/test.toml b/acceptance/cmd/auth/login/use-device-code-precondition/test.toml similarity index 52% rename from acceptance/cmd/auth/login/use-device-code/test.toml rename to acceptance/cmd/auth/login/use-device-code-precondition/test.toml index 84786fa60f0..36c0e7e237b 100644 --- a/acceptance/cmd/auth/login/use-device-code/test.toml +++ b/acceptance/cmd/auth/login/use-device-code-precondition/test.toml @@ -1,4 +1,3 @@ Ignore = [ "home" ] -RecordRequests = true diff --git a/acceptance/cmd/auth/login/use-device-code-unified-host/output.txt b/acceptance/cmd/auth/login/use-device-code-unified-host/output.txt deleted file mode 100644 index c5f77cc5597..00000000000 --- a/acceptance/cmd/auth/login/use-device-code-unified-host/output.txt +++ /dev/null @@ -1,5 +0,0 @@ - ->>> musterr [CLI] auth login --host [DATABRICKS_URL] --use-device-code -Error: --use-device-code is only supported for workspace hosts - ->>> print_requests.py //oidc/v1/device/auth diff --git a/acceptance/cmd/auth/login/use-device-code-unified-host/script b/acceptance/cmd/auth/login/use-device-code-unified-host/script deleted file mode 100644 index 51957a3f29b..00000000000 --- a/acceptance/cmd/auth/login/use-device-code-unified-host/script +++ /dev/null @@ -1,9 +0,0 @@ -sethome "./home" -export DATABRICKS_CLI_DEVICE_CODE=true - -# An account-scoped discovery URL marks the host as a unified host. -export DATABRICKS_DISCOVERY_URL="$DATABRICKS_HOST/oidc/accounts/spog-acct-123/.well-known/oauth-authorization-server" - -# Unified hosts are rejected before the device authorization request. -trace musterr $CLI auth login --host $DATABRICKS_HOST --use-device-code -trace print_requests.py //oidc/v1/device/auth diff --git a/acceptance/cmd/auth/login/use-device-code-unsupported-host/home/.databrickscfg.tmpl b/acceptance/cmd/auth/login/use-device-code-unsupported-host/home/.databrickscfg.tmpl new file mode 100644 index 00000000000..90baae2ed29 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-unsupported-host/home/.databrickscfg.tmpl @@ -0,0 +1,6 @@ +[workspace] +host = $DATABRICKS_HOST +auth_type = databricks-cli + +[account] +host = https://accounts.cloud.databricks.test diff --git a/acceptance/cmd/auth/login/use-device-code/out.test.toml b/acceptance/cmd/auth/login/use-device-code-unsupported-host/out.test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code/out.test.toml rename to acceptance/cmd/auth/login/use-device-code-unsupported-host/out.test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-unsupported-host/output.txt b/acceptance/cmd/auth/login/use-device-code-unsupported-host/output.txt new file mode 100644 index 00000000000..db3bc0beca5 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-unsupported-host/output.txt @@ -0,0 +1,30 @@ + +=== No host + +>>> musterr [CLI] auth login --profile new-profile --use-device-code +Error: --use-device-code requires a workspace host. Pass it as an argument or with --host + +=== Account host + +>>> musterr [CLI] auth login --host https://accounts.cloud.databricks.test --profile test --use-device-code +Error: --use-device-code is only supported for workspace hosts + +>>> musterr [CLI] auth login --profile account --use-device-code +Error: --use-device-code is only supported for workspace hosts + +=== Unified host + +>>> DATABRICKS_DISCOVERY_URL=[DATABRICKS_URL]/oidc/accounts/spog-acct-123/.well-known/oauth-authorization-server musterr [CLI] auth login --host [DATABRICKS_URL] --use-device-code +Error: --use-device-code is only supported for workspace hosts + +=== Workspace without device code login + +>>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code +Error: device code login is not enabled for this workspace + +>>> musterr [CLI] auth login --profile workspace --use-device-code +Error: device code login is not enabled for this workspace + +=== Device authorization requests + +>>> print_requests.py //oidc/v1/device/auth diff --git a/acceptance/cmd/auth/login/use-device-code-unsupported-host/script b/acceptance/cmd/auth/login/use-device-code-unsupported-host/script new file mode 100644 index 00000000000..487473725c2 --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-unsupported-host/script @@ -0,0 +1,28 @@ +sethome "./home" +export DATABRICKS_CLI_DEVICE_CODE=true + +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, which device code login does not support. +trace musterr $CLI auth login --profile new-profile --use-device-code + +envsubst < "./home/.databrickscfg.tmpl" > "./home/.databrickscfg" + +# The host is rejected before the CLI would prompt for an account ID, whether +# it is passed directly or comes from a profile without one. +title "Account host\n" +trace musterr $CLI auth login --host https://accounts.cloud.databricks.test --profile test --use-device-code +trace musterr $CLI auth login --profile account --use-device-code + +# An account-scoped discovery URL marks the host as a unified host. +title "Unified host\n" +trace DATABRICKS_DISCOVERY_URL="$DATABRICKS_HOST/oidc/accounts/spog-acct-123/.well-known/oauth-authorization-server" musterr $CLI auth login --host $DATABRICKS_HOST --use-device-code + +title "Workspace without device code login\n" +trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code +trace musterr $CLI auth login --profile workspace --use-device-code + +# Every host is rejected before the device authorization request. +title "Device authorization requests\n" +trace print_requests.py //oidc/v1/device/auth diff --git a/acceptance/cmd/auth/login/use-device-code-not-enabled/test.toml b/acceptance/cmd/auth/login/use-device-code-unsupported-host/test.toml similarity index 94% rename from acceptance/cmd/auth/login/use-device-code-not-enabled/test.toml rename to acceptance/cmd/auth/login/use-device-code-unsupported-host/test.toml index c1cdcb5caad..e0c12ef1a8d 100644 --- a/acceptance/cmd/auth/login/use-device-code-not-enabled/test.toml +++ b/acceptance/cmd/auth/login/use-device-code-unsupported-host/test.toml @@ -1,6 +1,7 @@ Ignore = [ "home" ] +RecordRequests = true # Workspaces that are not enrolled in the device code preview do not advertise # a device_authorization_endpoint in their OIDC discovery document. diff --git a/acceptance/cmd/auth/login/use-device-code-wrong-code/out.test.toml b/acceptance/cmd/auth/login/use-device-code-wrong-code/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-wrong-code/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/auth/login/use-device-code-wrong-code/output.txt b/acceptance/cmd/auth/login/use-device-code-wrong-code/output.txt new file mode 100644 index 00000000000..2bd0020be2f --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-wrong-code/output.txt @@ -0,0 +1,39 @@ + +=== 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: Confirmation code: Profile test was successfully saved diff --git a/acceptance/cmd/auth/login/use-device-code-wrong-code/script b/acceptance/cmd/auth/login/use-device-code-wrong-code/script new file mode 100644 index 00000000000..2c862ec82cb --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-wrong-code/script @@ -0,0 +1,14 @@ +sethome "./home" +export DATABRICKS_CLI_DEVICE_CODE=true + +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 asks again without contacting the workspace. +# 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/acceptance/cmd/auth/login/use-device-code-wrong-code/test.toml b/acceptance/cmd/auth/login/use-device-code-wrong-code/test.toml new file mode 100644 index 00000000000..36c0e7e237b --- /dev/null +++ b/acceptance/cmd/auth/login/use-device-code-wrong-code/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] diff --git a/acceptance/cmd/auth/login/use-device-code/output.txt b/acceptance/cmd/auth/login/use-device-code/output.txt deleted file mode 100644 index 2641db212f9..00000000000 --- a/acceptance/cmd/auth/login/use-device-code/output.txt +++ /dev/null @@ -1,166 +0,0 @@ - -=== 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 - -=== Inputs device code login cannot honor are rejected - ->>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --resource https://resource.test -Error: --use-device-code cannot be combined with --resource - ->>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --skip-workspace -Error: --use-device-code cannot be combined with --skip-workspace - ->>> musterr [CLI] auth login --profile new-profile --use-device-code -Error: --use-device-code requires a workspace host. Pass it as an argument or with --host - ->>> musterr [CLI] auth login --profile scoped --use-device-code -Error: --use-device-code cannot be used with profile scoped because its login is scoped to resources. Log in without --use-device-code - -=== Account hosts are rejected, whether passed directly or from a profile - ->>> musterr [CLI] auth login --host https://accounts.cloud.databricks.test --profile test --use-device-code -Error: --use-device-code is only supported for workspace hosts - ->>> musterr [CLI] auth login --profile account --use-device-code -Error: --use-device-code is only supported for workspace hosts - -=== 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: 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"; - -=== Device code requests - ->>> print_requests.py //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/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-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" -} -{ - "method": "POST", - "path": "/oidc/v1/token", - "raw_body": "client_id=databricks-cli&confirmation_code=BBBBBBBB&device_code=device-code-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" -} -{ - "method": "POST", - "path": "/oidc/v1/token", - "raw_body": "client_id=databricks-cli&confirmation_code=CCCCCCCC&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=AAAAAAAA&device_code=device-code-3&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" -} -{ - "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-4&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" -} - -=== Custom scopes are requested at device authorization - ->>> [CLI] auth login --host [DATABRICKS_URL] --profile custom-scopes --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 custom-scopes was successfully saved - ->>> print_requests.py //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-5&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" -} diff --git a/acceptance/cmd/auth/login/use-device-code/script b/acceptance/cmd/auth/login/use-device-code/script deleted file mode 100644 index 8c30d8bdffa..00000000000 --- a/acceptance/cmd/auth/login/use-device-code/script +++ /dev/null @@ -1,63 +0,0 @@ -sethome "./home" - -title "Device code login is disabled by default\n" -trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code - -export DATABRICKS_CLI_DEVICE_CODE=true - -title "Inputs device code login cannot honor are rejected\n" -trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --resource https://resource.test -trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --skip-workspace -# 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, which device code login does not support. -trace musterr $CLI auth login --profile new-profile --use-device-code -cat > ./home/.databrickscfg << EOF -[scoped] -host = $DATABRICKS_HOST -auth_type = databricks-cli -resources = https://resource.test -EOF -trace musterr $CLI auth login --profile scoped --use-device-code -rm ./home/.databrickscfg - -# The profile has no account ID: the host is rejected before the CLI would -# prompt for one. -title "Account hosts are rejected, whether passed directly or from a profile\n" -trace musterr $CLI auth login --host https://accounts.cloud.databricks.test --profile test --use-device-code -cat > ./home/.databrickscfg << EOF -[account] -host = https://accounts.cloud.databricks.test -EOF -trace musterr $CLI auth login --profile account --use-device-code -rm ./home/.databrickscfg - -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 asks again without contacting the workspace. -# 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 -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 - -title "Device code requests\n" -trace print_requests.py //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 "Custom scopes are requested at device authorization\n" -printf "FTFA-QQKY\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile custom-scopes --use-device-code --scopes sql -trace print_requests.py //oidc/v1/device/auth //oidc/v1/token - -# Track the .databrickscfg file that was created to surface changes. -mv "./home/.databrickscfg" "./out.databrickscfg" From 556ea934b2bc1a973405b8f7ce47f1e74fa590f6 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Thu, 22 Oct 2026 13:49:02 +0000 Subject: [PATCH 08/24] Remove the device code step from the login timeout acceptance test The prompt's handling of a timed-out context is covered by unit tests, and keeping stdin open in the acceptance test cost 5 seconds per run. Co-authored-by: Isaac --- acceptance/cmd/auth/login/timeout/output.txt | 10 ---------- acceptance/cmd/auth/login/timeout/script | 5 ----- 2 files changed, 15 deletions(-) diff --git a/acceptance/cmd/auth/login/timeout/output.txt b/acceptance/cmd/auth/login/timeout/output.txt index e0a45999bbd..07b20caaa9b 100644 --- a/acceptance/cmd/auth/login/timeout/output.txt +++ b/acceptance/cmd/auth/login/timeout/output.txt @@ -1,13 +1,3 @@ >>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --timeout 1s Error: authorize: context deadline exceeded - ->>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --timeout 1s -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: context deadline exceeded diff --git a/acceptance/cmd/auth/login/timeout/script b/acceptance/cmd/auth/login/timeout/script index 0948a21caf7..bbe57b6c03c 100644 --- a/acceptance/cmd/auth/login/timeout/script +++ b/acceptance/cmd/auth/login/timeout/script @@ -5,8 +5,3 @@ sethome "./home" export BROWSER="echo_browser.py" trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --timeout 1s > /dev/null - -# Keep stdin open without sending a confirmation code, so only --timeout ends -# the device code login. The sleep must outlast the timeout, otherwise the -# login fails with EOF instead. -sleep 5 | DATABRICKS_CLI_DEVICE_CODE=true trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --timeout 1s From ca42f5178aeeada33fc0b21285f3cb072659ec61 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 09:15:38 +0000 Subject: [PATCH 09/24] Update docs --- cmd/auth/login.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f8a924c1b5d..780275a0d72 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -614,9 +614,9 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, return nil } -// isAccountOrUnifiedHost reports whether the target host requires an account ID -// for OAuth URL construction. True for classic account hosts (accounts.*) and -// for unified hosts detected via account-scoped DiscoveryURL. +// isAccountOrUnifiedHost reports whether host is a classic account host +// (accounts.* or accounts-dod.*) or a unified host with an account-scoped +// discoveryURL. func isAccountOrUnifiedHost(host, discoveryURL string) bool { canonicalHost := (&config.Config{Host: host}).CanonicalHostName() if auth.IsClassicAccountHost(canonicalHost) { From 71f55db0f07e799a30a8b6ab24bd9440bd36522b Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 10:59:44 +0000 Subject: [PATCH 10/24] Pass the context to DeviceCodeChallenge Take the context as an argument instead of using the one stored in PersistentAuth, so that callers control the deadline and cancellation of the device code login. Co-authored-by: Isaac --- cmd/auth/login.go | 2 +- libs/auth/u2m/device_code.go | 6 +++--- libs/auth/u2m/device_code_test.go | 22 +++++++++++----------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 780275a0d72..82b64a1cdd1 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -403,7 +403,7 @@ a new profile is created. var token *oauth2.Token if useDeviceCode { - token, err = persistentAuth.DeviceCodeChallenge(promptForConfirmationCode) + token, err = persistentAuth.DeviceCodeChallenge(ctx, promptForConfirmationCode) } else { token, err = persistentAuth.Challenge() } diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 5f86c5a9a94..1aee057278c 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -82,13 +82,13 @@ func (a *PersistentAuth) deviceCodeConfig() (*oauth2.Config, error) { // confirmation code leaves the device code valid, so the user can re-enter // it up to maxConfirmationAttempts times. Only workspace hosts are supported. // The caller is responsible for storing the returned token. -func (a *PersistentAuth) DeviceCodeChallenge(confirm ConfirmFunc) (*oauth2.Token, error) { +func (a *PersistentAuth) DeviceCodeChallenge(ctx context.Context, confirm ConfirmFunc) (*oauth2.Token, error) { cfg, err := a.deviceCodeConfig() if err != nil { return nil, err } - ctx := a.setOAuthContext(a.ctx) + ctx = a.setOAuthContext(ctx) da, err := cfg.DeviceAuth(ctx) if err != nil { return nil, fmt.Errorf("device authorization: %w", oauthError(err)) @@ -100,7 +100,7 @@ func (a *PersistentAuth) DeviceCodeChallenge(confirm ConfirmFunc) (*oauth2.Token } var lastErr error for attempt := range maxConfirmationAttempts { - code, err := confirm(a.ctx, authorization, lastErr, maxConfirmationAttempts-attempt) + code, err := confirm(ctx, authorization, lastErr, maxConfirmationAttempts-attempt) if err != nil { return nil, err } diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 784f1c7895e..3bd28734874 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -115,7 +115,7 @@ func TestDeviceCodeChallenge(t *testing.T) { p := newDeviceCodeAuth(t, f, true) var got DeviceAuthorization - tok, err := p.DeviceCodeChallenge(func(_ context.Context, da DeviceAuthorization, _ error, _ int) (string, error) { + tok, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, da DeviceAuthorization, _ error, _ int) (string, error) { got = da return " ftfa-qqky\t", nil }) @@ -138,7 +138,7 @@ func TestDeviceCodeChallenge_ConfirmationAfterInitialExpiry(t *testing.T) { f := &fakeDeviceServer{expiresIn: 1} p := newDeviceCodeAuth(t, f, true) - tok, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + tok, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { time.Sleep(1100 * time.Millisecond) return testConfirmationCode, nil }) @@ -181,7 +181,7 @@ func TestDeviceCodeChallenge_Errors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.server.expiresIn = 600 p := newDeviceCodeAuth(t, tc.server, true) - _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + _, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { return testConfirmationCode, nil }) assert.EqualError(t, err, tc.wantErr) @@ -194,7 +194,7 @@ func TestDeviceCodeChallenge_RetriesWrongConfirmationCode(t *testing.T) { p := newDeviceCodeAuth(t, f, true) var lastErrs []string - tok, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, lastErr error, _ int) (string, error) { + tok, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, lastErr error, _ int) (string, error) { if lastErr == nil { lastErrs = append(lastErrs, "") return "AAAAAAAA", nil @@ -213,7 +213,7 @@ func TestDeviceCodeChallenge_TooManyWrongConfirmationCodes(t *testing.T) { p := newDeviceCodeAuth(t, f, true) var attemptsLeft []int - _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, left int) (string, error) { + _, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, left int) (string, error) { attemptsLeft = append(attemptsLeft, left) return "AAAAAAAA", nil }) @@ -227,7 +227,7 @@ func TestDeviceCodeChallenge_ConfirmErrorOnRetry(t *testing.T) { p := newDeviceCodeAuth(t, f, true) confirmErr := errors.New("no input") - _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, lastErr error, _ int) (string, error) { + _, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, lastErr error, _ int) (string, error) { if lastErr != nil { return "", confirmErr } @@ -248,7 +248,7 @@ func TestDeviceCodeChallenge_DoesNotPoll(t *testing.T) { } p := newDeviceCodeAuth(t, f, true, WithHttpClient(&http.Client{})) - _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + _, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { return testConfirmationCode, nil }) assert.EqualError(t, err, "token exchange: Login is not approved yet (error code: authorization_pending)") @@ -263,7 +263,7 @@ func TestDeviceCodeChallenge_MissingAccessToken(t *testing.T) { } p := newDeviceCodeAuth(t, f, true) - _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + _, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { return testConfirmationCode, nil }) assert.EqualError(t, err, "token exchange: server response missing access_token") @@ -274,7 +274,7 @@ func TestDeviceCodeChallenge_ConfirmError(t *testing.T) { p := newDeviceCodeAuth(t, f, true) confirmErr := errors.New("no input") - _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + _, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { return "", confirmErr }) assert.ErrorIs(t, err, confirmErr) @@ -284,7 +284,7 @@ func TestDeviceCodeChallenge_ConfirmError(t *testing.T) { func TestDeviceCodeChallenge_NotAdvertised(t *testing.T) { p := newDeviceCodeAuth(t, &fakeDeviceServer{}, false) - _, err := p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + _, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { t.Fatal("confirm must not be called") return "", nil }) @@ -301,7 +301,7 @@ func TestDeviceCodeChallenge_AccountHost(t *testing.T) { require.NoError(t, err) defer p.Close() - _, err = p.DeviceCodeChallenge(func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + _, err = p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { t.Fatal("confirm must not be called") return "", nil }) From ae65a33f3ee7ef73ef1e4e914ced41a30fa57b13 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 11:03:30 +0000 Subject: [PATCH 11/24] Send the device authorization request with the login context oauth2.Config.DeviceAuth only uses the context to pick the HTTP client and builds the request without it, so --timeout and Ctrl-C did not stop a stalled device authorization request. Make the request directly, sharing the request and error handling with the token exchange, and keep parsing the response with oauth2.DeviceAuthResponse. Co-authored-by: Isaac --- libs/auth/u2m/device_code.go | 86 ++++++++++++++++++++----------- libs/auth/u2m/device_code_test.go | 23 +++++++++ 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 1aee057278c..dfee0813d45 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -88,8 +88,7 @@ func (a *PersistentAuth) DeviceCodeChallenge(ctx context.Context, confirm Confir return nil, err } - ctx = a.setOAuthContext(ctx) - da, err := cfg.DeviceAuth(ctx) + da, err := a.requestDeviceAuthorization(ctx, cfg) if err != nil { return nil, fmt.Errorf("device authorization: %w", oauthError(err)) } @@ -121,6 +120,24 @@ func (a *PersistentAuth) DeviceCodeChallenge(ctx context.Context, confirm Confir return nil, fmt.Errorf("%w. Start login from scratch", lastErr) } +// requestDeviceAuthorization 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) { + body, err := a.postForm(ctx, cfg.Endpoint.DeviceAuthURL, url.Values{ + "client_id": {cfg.ClientID}, + "scope": {strings.Join(cfg.Scopes, " ")}, + }) + if err != nil { + return nil, err + } + var da oauth2.DeviceAuthResponse + if err := json.Unmarshal(body, &da); err != nil { + return nil, fmt.Errorf("parsing device authorization response: %w", err) + } + return &da, nil +} + // exchangeDeviceCode makes a single RFC 8628 device access token request. // The server issues the token as soon as it receives a valid confirmation // code, so unlike oauth2.Config.DeviceAccessToken this does not wait before @@ -130,40 +147,15 @@ func (a *PersistentAuth) DeviceCodeChallenge(ctx context.Context, confirm Confir func (a *PersistentAuth) exchangeDeviceCode(ctx context.Context, cfg *oauth2.Config, deviceCode, confirmationCode string) (*oauth2.Token, error) { // No scope: the token gets the scopes of the device authorization // request, and the server ignores a scope sent here. - form := url.Values{ + body, err := a.postForm(ctx, cfg.Endpoint.TokenURL, url.Values{ "grant_type": {deviceCodeGrantType}, "client_id": {cfg.ClientID}, "device_code": {deviceCode}, confirmationCodeParam: {confirmationCode}, - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.Endpoint.TokenURL, strings.NewReader(form.Encode())) + }) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := a.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return nil, fmt.Errorf("reading token response: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode > 299 { - retrieveErr := &oauth2.RetrieveError{Response: resp, Body: body} - var errResp struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - if json.Unmarshal(body, &errResp) == nil { - retrieveErr.ErrorCode = errResp.Error - retrieveErr.ErrorDescription = errResp.ErrorDescription - } - return nil, retrieveErr - } var tokenResp struct { AccessToken string `json:"access_token"` @@ -193,6 +185,42 @@ func (a *PersistentAuth) exchangeDeviceCode(ctx context.Context, cfg *oauth2.Con return t.WithExtra(raw), nil } +// postForm sends a form-encoded OAuth request and returns the body of a +// successful response. An error response is returned as an +// *oauth2.RetrieveError with the RFC 6749 §5.2 error fields. +func (a *PersistentAuth) postForm(ctx context.Context, endpoint string, form url.Values) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + retrieveErr := &oauth2.RetrieveError{Response: resp, Body: body} + var errResp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if json.Unmarshal(body, &errResp) == nil { + retrieveErr.ErrorCode = errResp.Error + retrieveErr.ErrorDescription = errResp.ErrorDescription + } + return nil, retrieveErr + } + return body, nil +} + // 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 diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 3bd28734874..3da8fd481f5 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -42,6 +42,9 @@ func (s deviceCodeEndpointSupplier) GetWorkspaceOAuthEndpoints(_ context.Context type fakeDeviceServer struct { deviceAuthStatus int deviceAuthBody string + // deviceAuthStalls makes the device authorization request hang until + // the client gives up on it. + deviceAuthStalls bool tokenStatus int tokenBody string expiresIn int @@ -55,6 +58,10 @@ func (f *fakeDeviceServer) handler(t *testing.T) http.Handler { assert.NoError(t, r.ParseForm()) assert.Equal(t, "databricks-cli", r.PostForm.Get("client_id")) assert.Equal(t, "offline_access all-apis", r.PostForm.Get("scope")) + if f.deviceAuthStalls { + <-r.Context().Done() + return + } w.Header().Set("Content-Type", "application/json") if f.deviceAuthStatus != 0 { w.WriteHeader(f.deviceAuthStatus) @@ -281,6 +288,22 @@ func TestDeviceCodeChallenge_ConfirmError(t *testing.T) { assert.Equal(t, int32(0), f.tokenRequests.Load()) } +func TestDeviceCodeChallenge_DeviceAuthorizationCancelled(t *testing.T) { + p := newDeviceCodeAuth(t, &fakeDeviceServer{deviceAuthStalls: true}, true) + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := p.DeviceCodeChallenge(ctx, func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + t.Fatal("confirm must not be called") + return "", nil + }) + assert.ErrorIs(t, err, context.DeadlineExceeded) + // The HTTP client's own 30 second timeout also ends the request with + // DeadlineExceeded, so check that the context ended it. + assert.Less(t, time.Since(start), 10*time.Second) +} + func TestDeviceCodeChallenge_NotAdvertised(t *testing.T) { p := newDeviceCodeAuth(t, &fakeDeviceServer{}, false) From c0421b890127358065e5eddb2ffcb7b1e0c13419 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 11:04:53 +0000 Subject: [PATCH 12/24] Reject resource indicators in device code login PersistentAuth only sends resource indicators on the browser authorization request, so a device code login with WithResources would silently return a token that is not scoped to them. The CLI already rejects --resource and resource-scoped profiles; reject it in the library too. Co-authored-by: Isaac --- libs/auth/u2m/device_code.go | 5 +++++ libs/auth/u2m/device_code_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index dfee0813d45..0e4141ec911 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -65,6 +65,11 @@ func (a *PersistentAuth) deviceCodeConfig() (*oauth2.Config, error) { if _, ok := a.oAuthArgument.(WorkspaceOAuthArgument); !ok { return nil, errors.New("device code login is only supported for workspace hosts") } + // Resource indicators are only sent on the browser authorization request, + // so the token would not be scoped to them. + if len(a.resources) > 0 { + return nil, errors.New("device code login does not support resource indicators") + } cfg, err := a.oauth2Config() if err != nil { return nil, fmt.Errorf("fetching oauth config: %w", err) diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 3da8fd481f5..087731d463b 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -314,6 +314,16 @@ func TestDeviceCodeChallenge_NotAdvertised(t *testing.T) { assert.ErrorIs(t, err, ErrDeviceCodeNotSupported) } +func TestDeviceCodeChallenge_Resources(t *testing.T) { + p := newDeviceCodeAuth(t, &fakeDeviceServer{}, true, WithResources([]string{"https://resource.test"})) + + _, err := p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + t.Fatal("confirm must not be called") + return "", nil + }) + assert.EqualError(t, err, "device code login does not support resource indicators") +} + func TestDeviceCodeChallenge_AccountHost(t *testing.T) { arg, err := NewBasicAccountOAuthArgument("https://accounts.cloud.databricks.test", "xyz") require.NoError(t, err) From 817d903b35062e0a5559fb7d8d6f0402b00ade67 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 11:05:56 +0000 Subject: [PATCH 13/24] Simplify the device code host check The arguments never carry a discovery URL before setHostAndAccountId runs, so the first isAccountOrUnifiedHost call only checked for a classic account host. Check for a classic account host, run host discovery, and check the discovered URL for a unified host. Co-authored-by: Isaac --- cmd/auth/login_device_code.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index fd6dc970fa3..d6ba3f4c6e4 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -37,16 +37,15 @@ func validateDeviceCodeHost(ctx context.Context, authArguments *auth.AuthArgumen // workspace hosts, which must not change the default profile name. probe := *authArguments probe.Host = auth.ExtractHostQueryParams(probe.Host).Host - if isAccountOrUnifiedHost(probe.Host, probe.DiscoveryURL) { + host := (&config.Config{Host: probe.Host}).CanonicalHostName() + if auth.IsClassicAccountHost(host) { return errDeviceCodeWorkspaceOnly } - if probe.DiscoveryURL == "" { - runHostDiscovery(ctx, &probe) - if isAccountOrUnifiedHost(probe.Host, probe.DiscoveryURL) { - return errDeviceCodeWorkspaceOnly - } + runHostDiscovery(ctx, &probe) + if auth.HasUnifiedHostSignal(probe.DiscoveryURL) { + return errDeviceCodeWorkspaceOnly } - arg, err := u2m.NewBasicWorkspaceOAuthArgument((&config.Config{Host: probe.Host}).CanonicalHostName()) + arg, err := u2m.NewBasicWorkspaceOAuthArgument(host) if err != nil { return err } From 87a2d5bde2877945f9e6a84ab94c1239d59dd7d6 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 11:30:32 +0000 Subject: [PATCH 14/24] Revert the rename of needsAccountIDPrompt The device code host check no longer calls it, so it is only used again to decide whether setHostAndAccountId prompts for an account ID. Co-authored-by: Isaac --- cmd/auth/login.go | 11 +++++------ cmd/auth/login_test.go | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 82b64a1cdd1..0a844907509 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -598,8 +598,7 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, // are logged as warnings and never block login. runHostDiscovery(ctx, authArguments) - needsAccountIDPrompt := isAccountOrUnifiedHost(authArguments.Host, authArguments.DiscoveryURL) && authArguments.AccountID == "" - if needsAccountIDPrompt { + if needsAccountIDPrompt(authArguments.Host, authArguments.DiscoveryURL) && authArguments.AccountID == "" { if existingProfile != nil && existingProfile.AccountID != "" { authArguments.AccountID = existingProfile.AccountID } else { @@ -614,10 +613,10 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, return nil } -// isAccountOrUnifiedHost reports whether host is a classic account host -// (accounts.* or accounts-dod.*) or a unified host with an account-scoped -// discoveryURL. -func isAccountOrUnifiedHost(host, discoveryURL string) bool { +// needsAccountIDPrompt reports whether the target host requires an account ID +// for OAuth URL construction. True for classic account hosts (accounts.*) and +// for unified hosts detected via account-scoped DiscoveryURL. +func needsAccountIDPrompt(host, discoveryURL string) bool { canonicalHost := (&config.Config{Host: host}).CanonicalHostName() if auth.IsClassicAccountHost(canonicalHost) { return true diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 1c9d5b825d0..026a002a158 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -424,7 +424,7 @@ func TestShouldUseDiscovery(t *testing.T) { } } -func TestIsAccountOrUnifiedHost(t *testing.T) { +func TestNeedsAccountIDPrompt(t *testing.T) { cases := []struct { name string host string @@ -441,7 +441,7 @@ func TestIsAccountOrUnifiedHost(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := isAccountOrUnifiedHost(tc.host, tc.discoveryURL) + got := needsAccountIDPrompt(tc.host, tc.discoveryURL) assert.Equal(t, tc.want, got) }) } From 1662882163ac46e2d3662ff3ba1a05dc02830bfd Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 11:06:54 +0000 Subject: [PATCH 15/24] Use the account ID from the host in the device code host check Host discovery skips the discovery URL of a unified host whose metadata has an {account_id} placeholder when no account ID is known. The device code host check dropped the ?a= account ID from the host, so it treated such a host as a workspace, while setHostAndAccountId later resolved it as unified. Co-authored-by: Isaac --- cmd/auth/login_device_code.go | 8 +++++++- cmd/auth/login_device_code_test.go | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index d6ba3f4c6e4..414514694ab 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -36,7 +36,13 @@ func validateDeviceCodeHost(ctx context.Context, authArguments *auth.AuthArgumen // Work on a copy: host discovery fills in the account ID even for // workspace hosts, which must not change the default profile name. probe := *authArguments - probe.Host = auth.ExtractHostQueryParams(probe.Host).Host + params := auth.ExtractHostQueryParams(probe.Host) + probe.Host = params.Host + // Like setHostAndAccountId, use the account ID from the host for + // discovery, which needs it to resolve some unified hosts. + if probe.AccountID == "" { + probe.AccountID = params.AccountID + } host := (&config.Config{Host: probe.Host}).CanonicalHostName() if auth.IsClassicAccountHost(host) { return errDeviceCodeWorkspaceOnly diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 8696b278d3f..a82c0bfb1fc 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -177,6 +177,11 @@ func TestValidateDeviceCodeHost(t *testing.T) { "account_id": "acct-123", "oidc_endpoint": "https://spog.test/oidc/accounts/acct-123", }) + // Without an account ID, host discovery cannot fill in the placeholder + // and does not resolve the account-scoped discovery URL. + unifiedWithoutAccountID := newDiscoveryServer(t, map[string]any{ + "oidc_endpoint": "https://spog.test/oidc/accounts/{account_id}", + }) tests := []struct { name string @@ -189,6 +194,8 @@ func TestValidateDeviceCodeHost(t *testing.T) { {name: "classic account host", args: auth.AuthArguments{Host: "https://accounts.cloud.databricks.test"}, wantErr: errDeviceCodeWorkspaceOnly}, {name: "unified host", args: auth.AuthArguments{Host: unified.URL}, wantErr: errDeviceCodeWorkspaceOnly}, {name: "unified host with query params", args: auth.AuthArguments{Host: unified.URL + "?o=12345"}, wantErr: errDeviceCodeWorkspaceOnly}, + {name: "unified host with account ID in query params", args: auth.AuthArguments{Host: unifiedWithoutAccountID.URL + "?a=acct-123"}, wantErr: errDeviceCodeWorkspaceOnly}, + {name: "unified host with account ID flag", args: auth.AuthArguments{Host: unifiedWithoutAccountID.URL, AccountID: "acct-123"}, wantErr: errDeviceCodeWorkspaceOnly}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 05cdba64ca6883611059e66745cf1d4a65a33ace Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 11:09:43 +0000 Subject: [PATCH 16/24] Group the device code acceptance tests under device-code/ Move the shared DATABRICKS_CLI_DEVICE_CODE setting and the home ignore into a parent test.toml. The precondition test unsets the variable for the case that checks the feature is disabled by default. Co-authored-by: Isaac --- .../client-not-allowed}/out.test.toml | 0 .../client-not-allowed}/output.txt | 0 .../client-not-allowed}/script | 1 - .../nominal}/out.databrickscfg | 0 .../nominal}/out.test.toml | 0 .../nominal}/output.txt | 0 .../nominal}/script | 1 - acceptance/cmd/auth/login/device-code/nominal/test.toml | 1 + .../precondition}/home/.databrickscfg.tmpl | 0 .../precondition}/out.test.toml | 0 .../precondition}/output.txt | 0 .../precondition}/script | 7 ++++--- acceptance/cmd/auth/login/device-code/test.toml | 4 ++++ .../unsupported-host}/home/.databrickscfg.tmpl | 0 .../unsupported-host}/out.test.toml | 0 .../unsupported-host}/output.txt | 0 .../unsupported-host}/script | 1 - .../unsupported-host}/test.toml | 3 --- .../wrong-code}/out.test.toml | 0 .../wrong-code}/output.txt | 0 .../wrong-code}/script | 1 - .../login/use-device-code-client-not-allowed/test.toml | 3 --- .../cmd/auth/login/use-device-code-nominal/test.toml | 4 ---- .../cmd/auth/login/use-device-code-precondition/test.toml | 3 --- .../cmd/auth/login/use-device-code-wrong-code/test.toml | 3 --- 25 files changed, 9 insertions(+), 23 deletions(-) rename acceptance/cmd/auth/login/{use-device-code-client-not-allowed => device-code/client-not-allowed}/out.test.toml (100%) rename acceptance/cmd/auth/login/{use-device-code-client-not-allowed => device-code/client-not-allowed}/output.txt (100%) rename acceptance/cmd/auth/login/{use-device-code-client-not-allowed => device-code/client-not-allowed}/script (77%) rename acceptance/cmd/auth/login/{use-device-code-nominal => device-code/nominal}/out.databrickscfg (100%) rename acceptance/cmd/auth/login/{use-device-code-nominal => device-code/nominal}/out.test.toml (100%) rename acceptance/cmd/auth/login/{use-device-code-nominal => device-code/nominal}/output.txt (100%) rename acceptance/cmd/auth/login/{use-device-code-nominal => device-code/nominal}/script (96%) create mode 100644 acceptance/cmd/auth/login/device-code/nominal/test.toml rename acceptance/cmd/auth/login/{use-device-code-precondition => device-code/precondition}/home/.databrickscfg.tmpl (100%) rename acceptance/cmd/auth/login/{use-device-code-precondition => device-code/precondition}/out.test.toml (100%) rename acceptance/cmd/auth/login/{use-device-code-precondition => device-code/precondition}/output.txt (100%) rename acceptance/cmd/auth/login/{use-device-code-precondition => device-code/precondition}/script (80%) create mode 100644 acceptance/cmd/auth/login/device-code/test.toml rename acceptance/cmd/auth/login/{use-device-code-unsupported-host => device-code/unsupported-host}/home/.databrickscfg.tmpl (100%) rename acceptance/cmd/auth/login/{use-device-code-unsupported-host => device-code/unsupported-host}/out.test.toml (100%) rename acceptance/cmd/auth/login/{use-device-code-unsupported-host => device-code/unsupported-host}/output.txt (100%) rename acceptance/cmd/auth/login/{use-device-code-unsupported-host => device-code/unsupported-host}/script (97%) rename acceptance/cmd/auth/login/{use-device-code-unsupported-host => device-code/unsupported-host}/test.toml (94%) rename acceptance/cmd/auth/login/{use-device-code-wrong-code => device-code/wrong-code}/out.test.toml (100%) rename acceptance/cmd/auth/login/{use-device-code-wrong-code => device-code/wrong-code}/output.txt (100%) rename acceptance/cmd/auth/login/{use-device-code-wrong-code => device-code/wrong-code}/script (95%) delete mode 100644 acceptance/cmd/auth/login/use-device-code-client-not-allowed/test.toml delete mode 100644 acceptance/cmd/auth/login/use-device-code-nominal/test.toml delete mode 100644 acceptance/cmd/auth/login/use-device-code-precondition/test.toml delete mode 100644 acceptance/cmd/auth/login/use-device-code-wrong-code/test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/out.test.toml b/acceptance/cmd/auth/login/device-code/client-not-allowed/out.test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-client-not-allowed/out.test.toml rename to acceptance/cmd/auth/login/device-code/client-not-allowed/out.test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/output.txt b/acceptance/cmd/auth/login/device-code/client-not-allowed/output.txt similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-client-not-allowed/output.txt rename to acceptance/cmd/auth/login/device-code/client-not-allowed/output.txt diff --git a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/script b/acceptance/cmd/auth/login/device-code/client-not-allowed/script similarity index 77% rename from acceptance/cmd/auth/login/use-device-code-client-not-allowed/script rename to acceptance/cmd/auth/login/device-code/client-not-allowed/script index 0627881dc65..4197d2fdf24 100644 --- a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/script +++ b/acceptance/cmd/auth/login/device-code/client-not-allowed/script @@ -1,4 +1,3 @@ sethome "./home" -export DATABRICKS_CLI_DEVICE_CODE=true trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --client-id custom-client-id diff --git a/acceptance/cmd/auth/login/use-device-code-nominal/out.databrickscfg b/acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-nominal/out.databrickscfg rename to acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg diff --git a/acceptance/cmd/auth/login/use-device-code-nominal/out.test.toml b/acceptance/cmd/auth/login/device-code/nominal/out.test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-nominal/out.test.toml rename to acceptance/cmd/auth/login/device-code/nominal/out.test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-nominal/output.txt b/acceptance/cmd/auth/login/device-code/nominal/output.txt similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-nominal/output.txt rename to acceptance/cmd/auth/login/device-code/nominal/output.txt diff --git a/acceptance/cmd/auth/login/use-device-code-nominal/script b/acceptance/cmd/auth/login/device-code/nominal/script similarity index 96% rename from acceptance/cmd/auth/login/use-device-code-nominal/script rename to acceptance/cmd/auth/login/device-code/nominal/script index 145e6d4fced..f1d6c04f59a 100644 --- a/acceptance/cmd/auth/login/use-device-code-nominal/script +++ b/acceptance/cmd/auth/login/device-code/nominal/script @@ -1,5 +1,4 @@ sethome "./home" -export DATABRICKS_CLI_DEVICE_CODE=true title "Login with a host\n" printf "FTFA-QQKY\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code diff --git a/acceptance/cmd/auth/login/device-code/nominal/test.toml b/acceptance/cmd/auth/login/device-code/nominal/test.toml new file mode 100644 index 00000000000..159efe02696 --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/nominal/test.toml @@ -0,0 +1 @@ +RecordRequests = true diff --git a/acceptance/cmd/auth/login/use-device-code-precondition/home/.databrickscfg.tmpl b/acceptance/cmd/auth/login/device-code/precondition/home/.databrickscfg.tmpl similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-precondition/home/.databrickscfg.tmpl rename to acceptance/cmd/auth/login/device-code/precondition/home/.databrickscfg.tmpl diff --git a/acceptance/cmd/auth/login/use-device-code-precondition/out.test.toml b/acceptance/cmd/auth/login/device-code/precondition/out.test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-precondition/out.test.toml rename to acceptance/cmd/auth/login/device-code/precondition/out.test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-precondition/output.txt b/acceptance/cmd/auth/login/device-code/precondition/output.txt similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-precondition/output.txt rename to acceptance/cmd/auth/login/device-code/precondition/output.txt diff --git a/acceptance/cmd/auth/login/use-device-code-precondition/script b/acceptance/cmd/auth/login/device-code/precondition/script similarity index 80% rename from acceptance/cmd/auth/login/use-device-code-precondition/script rename to acceptance/cmd/auth/login/device-code/precondition/script index 90dd16f9cc5..c3a7a3208c0 100644 --- a/acceptance/cmd/auth/login/use-device-code-precondition/script +++ b/acceptance/cmd/auth/login/device-code/precondition/script @@ -1,9 +1,10 @@ sethome "./home" title "Device code login is disabled by default\n" -trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code - -export DATABRICKS_CLI_DEVICE_CODE=true +( + unset DATABRICKS_CLI_DEVICE_CODE + trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code +) title "Inputs device code login cannot honor are rejected\n" trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --resource https://resource.test diff --git a/acceptance/cmd/auth/login/device-code/test.toml b/acceptance/cmd/auth/login/device-code/test.toml new file mode 100644 index 00000000000..c8487e95d6e --- /dev/null +++ b/acceptance/cmd/auth/login/device-code/test.toml @@ -0,0 +1,4 @@ +Env.DATABRICKS_CLI_DEVICE_CODE = "true" +Ignore = [ + "home" +] diff --git a/acceptance/cmd/auth/login/use-device-code-unsupported-host/home/.databrickscfg.tmpl b/acceptance/cmd/auth/login/device-code/unsupported-host/home/.databrickscfg.tmpl similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-unsupported-host/home/.databrickscfg.tmpl rename to acceptance/cmd/auth/login/device-code/unsupported-host/home/.databrickscfg.tmpl diff --git a/acceptance/cmd/auth/login/use-device-code-unsupported-host/out.test.toml b/acceptance/cmd/auth/login/device-code/unsupported-host/out.test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-unsupported-host/out.test.toml rename to acceptance/cmd/auth/login/device-code/unsupported-host/out.test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-unsupported-host/output.txt b/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-unsupported-host/output.txt rename to acceptance/cmd/auth/login/device-code/unsupported-host/output.txt diff --git a/acceptance/cmd/auth/login/use-device-code-unsupported-host/script b/acceptance/cmd/auth/login/device-code/unsupported-host/script similarity index 97% rename from acceptance/cmd/auth/login/use-device-code-unsupported-host/script rename to acceptance/cmd/auth/login/device-code/unsupported-host/script index 487473725c2..dd8a9ddca47 100644 --- a/acceptance/cmd/auth/login/use-device-code-unsupported-host/script +++ b/acceptance/cmd/auth/login/device-code/unsupported-host/script @@ -1,5 +1,4 @@ sethome "./home" -export DATABRICKS_CLI_DEVICE_CODE=true title "No host\n" # The profile does not exist yet and no host is given. auth login does not read diff --git a/acceptance/cmd/auth/login/use-device-code-unsupported-host/test.toml b/acceptance/cmd/auth/login/device-code/unsupported-host/test.toml similarity index 94% rename from acceptance/cmd/auth/login/use-device-code-unsupported-host/test.toml rename to acceptance/cmd/auth/login/device-code/unsupported-host/test.toml index e0c12ef1a8d..9eb77236bab 100644 --- a/acceptance/cmd/auth/login/use-device-code-unsupported-host/test.toml +++ b/acceptance/cmd/auth/login/device-code/unsupported-host/test.toml @@ -1,6 +1,3 @@ -Ignore = [ - "home" -] RecordRequests = true # Workspaces that are not enrolled in the device code preview do not advertise diff --git a/acceptance/cmd/auth/login/use-device-code-wrong-code/out.test.toml b/acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-wrong-code/out.test.toml rename to acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml diff --git a/acceptance/cmd/auth/login/use-device-code-wrong-code/output.txt b/acceptance/cmd/auth/login/device-code/wrong-code/output.txt similarity index 100% rename from acceptance/cmd/auth/login/use-device-code-wrong-code/output.txt rename to acceptance/cmd/auth/login/device-code/wrong-code/output.txt diff --git a/acceptance/cmd/auth/login/use-device-code-wrong-code/script b/acceptance/cmd/auth/login/device-code/wrong-code/script similarity index 95% rename from acceptance/cmd/auth/login/use-device-code-wrong-code/script rename to acceptance/cmd/auth/login/device-code/wrong-code/script index 2c862ec82cb..9f0a8e9ae09 100644 --- a/acceptance/cmd/auth/login/use-device-code-wrong-code/script +++ b/acceptance/cmd/auth/login/device-code/wrong-code/script @@ -1,5 +1,4 @@ sethome "./home" -export DATABRICKS_CLI_DEVICE_CODE=true 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 diff --git a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/test.toml b/acceptance/cmd/auth/login/use-device-code-client-not-allowed/test.toml deleted file mode 100644 index 36c0e7e237b..00000000000 --- a/acceptance/cmd/auth/login/use-device-code-client-not-allowed/test.toml +++ /dev/null @@ -1,3 +0,0 @@ -Ignore = [ - "home" -] diff --git a/acceptance/cmd/auth/login/use-device-code-nominal/test.toml b/acceptance/cmd/auth/login/use-device-code-nominal/test.toml deleted file mode 100644 index 84786fa60f0..00000000000 --- a/acceptance/cmd/auth/login/use-device-code-nominal/test.toml +++ /dev/null @@ -1,4 +0,0 @@ -Ignore = [ - "home" -] -RecordRequests = true diff --git a/acceptance/cmd/auth/login/use-device-code-precondition/test.toml b/acceptance/cmd/auth/login/use-device-code-precondition/test.toml deleted file mode 100644 index 36c0e7e237b..00000000000 --- a/acceptance/cmd/auth/login/use-device-code-precondition/test.toml +++ /dev/null @@ -1,3 +0,0 @@ -Ignore = [ - "home" -] diff --git a/acceptance/cmd/auth/login/use-device-code-wrong-code/test.toml b/acceptance/cmd/auth/login/use-device-code-wrong-code/test.toml deleted file mode 100644 index 36c0e7e237b..00000000000 --- a/acceptance/cmd/auth/login/use-device-code-wrong-code/test.toml +++ /dev/null @@ -1,3 +0,0 @@ -Ignore = [ - "home" -] From 89b41a97a000b2e32cd50a14af31a564f299f8e9 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 11:56:21 +0000 Subject: [PATCH 17/24] Document the device code errors the workspace returns The workspace answers an unknown device code with HTTP 400 invalid_request, like a wrong confirmation code, so the CLI asks for the code again and the error never has the "token exchange:" prefix. Also document the error for too many token requests. Co-authored-by: Isaac --- docs/device-code-login.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/device-code-login.md b/docs/device-code-login.md index 50bb63c2163..b62b0436fc7 100644 --- a/docs/device-code-login.md +++ b/docs/device-code-login.md @@ -61,7 +61,8 @@ fail with a token refresh error and you need to run the login again. | `--use-device-code is only supported for workspace hosts` | The host is an account or unified host. | Log in to a workspace host instead. | | The browser says the code is invalid or expired | The code was mistyped, or more than ~10 minutes passed. | Re-enter the code carefully, or run the login again for a new code. Codes never contain `0`, `1`, `2`, `O`, `Z`, or `I`. | | `Invalid confirmation code` | The confirmation code was mistyped. | Re-enter the code. After 3 rejected codes, the login fails with `Invalid confirmation code (error code: invalid_request). Start login from scratch` and you need to run it again. | -| `token exchange: Invalid device code` | The login was already completed or expired. | Run the login again to get a new code. | +| `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. | Re-entering the code does not help. 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. | | `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. | From 9b63504e98eb4c25c4ad4b4e91c5ff0ad860b7be Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 11:57:59 +0000 Subject: [PATCH 18/24] Count a blank confirmation code as a wrong attempt A blank line asked for the confirmation code again without limit, so anything that keeps sending newlines kept the login waiting until the timeout. Send it like any other code instead: the workspace rejects an empty confirmation code, and it counts as one of the 3 attempts. Co-authored-by: Isaac --- .../login/device-code/wrong-code/output.txt | 3 ++- .../auth/login/device-code/wrong-code/script | 2 +- cmd/auth/login_device_code.go | 17 +++++++---------- cmd/auth/login_device_code_test.go | 3 +-- docs/device-code-login.md | 2 +- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/acceptance/cmd/auth/login/device-code/wrong-code/output.txt b/acceptance/cmd/auth/login/device-code/wrong-code/output.txt index 2bd0020be2f..2ba7f3ea23e 100644 --- a/acceptance/cmd/auth/login/device-code/wrong-code/output.txt +++ b/acceptance/cmd/auth/login/device-code/wrong-code/output.txt @@ -36,4 +36,5 @@ 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: Confirmation code: Profile test was successfully saved +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 index 9f0a8e9ae09..3adb0de81a3 100644 --- a/acceptance/cmd/auth/login/device-code/wrong-code/script +++ b/acceptance/cmd/auth/login/device-code/wrong-code/script @@ -7,7 +7,7 @@ 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 asks again without contacting the workspace. +# 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.go b/cmd/auth/login_device_code.go index 414514694ab..d2b383650f2 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -102,17 +102,14 @@ func promptForConfirmationCode(ctx context.Context, da u2m.DeviceAuthorization, cmdio.LogString(ctx, fmt.Sprintf("%v. Enter the confirmation code again (%d %s left).", lastErr, attemptsLeft, attempts)) } - // Ask again on a blank line, e.g. when the user pressed Enter by accident. - // A cancelled read fails the login, so stdin is not read again after it. - for { - code, err := cmdio.AskCancellable(ctx, "Confirmation code", "") - if err != nil { - return "", fmt.Errorf("reading confirmation code: %w", err) - } - if strings.TrimSpace(code) != "" { - return code, nil - } + // A blank line is sent like any other code, so the server rejects it and + // it counts as one of the attempts. A cancelled read fails the login, so + // stdin is not read again after it. + code, err := cmdio.AskCancellable(ctx, "Confirmation code", "") + if err != nil { + return "", fmt.Errorf("reading confirmation code: %w", err) } + return code, nil } // formatCode displays an 8-character device code as two groups of four diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index a82c0bfb1fc..2da44afbb46 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -92,9 +92,8 @@ func TestPromptForConfirmationCode(t *testing.T) { }{ {name: "code", input: "FTFA-QQKY\n", want: "FTFA-QQKY"}, {name: "code without newline", input: "FTFA-QQKY", want: "FTFA-QQKY"}, - {name: "blank line", input: " \nFTFA-QQKY\n", want: "FTFA-QQKY"}, + {name: "blank line", input: " \nFTFA-QQKY\n", want: " "}, {name: "no input", input: "", wantErr: "reading confirmation code: EOF"}, - {name: "blank line without code", input: " \n", wantErr: "reading confirmation code: EOF"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/docs/device-code-login.md b/docs/device-code-login.md index b62b0436fc7..b4b18278e2d 100644 --- a/docs/device-code-login.md +++ b/docs/device-code-login.md @@ -60,7 +60,7 @@ fail with a token refresh error and you need to run the login again. | `device code login is not enabled for this workspace` | The workspace does not advertise device code login. | Use a workspace where the feature is enabled. | | `--use-device-code is only supported for workspace hosts` | The host is an account or unified host. | Log in to a workspace host instead. | | The browser says the code is invalid or expired | The code was mistyped, or more than ~10 minutes passed. | Re-enter the code carefully, or run the login again for a new code. Codes never contain `0`, `1`, `2`, `O`, `Z`, or `I`. | -| `Invalid confirmation code` | The confirmation code was mistyped. | Re-enter the code. After 3 rejected codes, the login fails with `Invalid confirmation code (error code: invalid_request). Start login from scratch` and you need to run it again. | +| `Invalid confirmation code` | The confirmation code was mistyped or left blank. | Re-enter the code. After 3 rejected codes, the login fails with `Invalid confirmation code (error code: invalid_request). Start login from scratch` and you need to run it again. | | `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. | Re-entering the code does not help. 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. | From ea2e74f6697ce587aed19211fc4c0967951d8ea5 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 12:03:18 +0000 Subject: [PATCH 19/24] Check the device code host once, after it is resolved The host was checked separately for every place it can come from: the flags, the profile picker and the profile. Split setHostAndAccountId into setHost and setAccountID and check the resolved host once in between, before setAccountID prompts for an account ID for account and unified hosts. This reuses the host discovery of setHost instead of running it again. A workspace without device code login enabled is now rejected by DeviceCodeChallenge, which runs without a prompt in between and fails before creating a device code, so drop CheckDeviceCodeSupported. An unsupported host given with --host now fails after the profile name prompt instead of before it. Co-authored-by: Isaac --- .../device-code/unsupported-host/output.txt | 1 + cmd/auth/README.md | 3 +- cmd/auth/login.go | 60 ++++++++----------- cmd/auth/login_device_code.go | 38 +++--------- cmd/auth/login_device_code_test.go | 39 +++--------- libs/auth/u2m/device_code.go | 9 --- libs/auth/u2m/device_code_test.go | 17 ------ 7 files changed, 43 insertions(+), 124 deletions(-) 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 db3bc0beca5..5d327ce2db2 100644 --- a/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt +++ b/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt @@ -7,6 +7,7 @@ Error: --use-device-code requires a workspace host. Pass it as an argument or wi === Account host >>> musterr [CLI] auth login --host https://accounts.cloud.databricks.test --profile test --use-device-code +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 >>> musterr [CLI] auth login --profile account --use-device-code diff --git a/cmd/auth/README.md b/cmd/auth/README.md index 1de89a535c5..cf53ff9c873 100644 --- a/cmd/auth/README.md +++ b/cmd/auth/README.md @@ -89,7 +89,8 @@ Notes on the server behavior the CLI relies on: - Workspaces advertise `device_authorization_endpoint` in `/oidc/.well-known/oauth-authorization-server` only when device code login - is enabled for them. The CLI fails early if it is missing. + is enabled for them. The CLI fails before requesting a device code if it is + missing. - The token request carries the confirmation code in the `confirmation_code` form parameter. A wrong confirmation code does not consume the device code; a successful exchange does. The server answers a wrong confirmation code diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 0a844907509..5114ab8f78f 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -205,18 +205,10 @@ a new profile is created. } } - // Check a known host before the keyring probe and any prompt, so that - // an unsupported host or workspace fails right away. A host that is - // only known later is checked as soon as it is known. if useDeviceCode { if err := validateDeviceCodeLogin(ctx, cmd); err != nil { return err } - if authArguments.Host != "" { - if err := validateDeviceCodeHost(ctx, authArguments); err != nil { - return err - } - } } // Resolve the cache before the browser step so an unavailable @@ -261,17 +253,7 @@ a new profile is created. return err } authArguments.Host = host - if useDeviceCode { - if err := validateDeviceCodeHost(ctx, authArguments); err != nil { - return err - } - } case profilePickerCreateNew: - // A new profile without a host uses the discovery login, - // which device code login does not support. - if useDeviceCode { - return errDeviceCodeNeedsHost - } // Fall through to the profile name prompt below. } } @@ -345,18 +327,18 @@ a new profile is created. }) } - // Without a host from the flags or the picker, the host comes from the - // profile. Check it before setHostAndAccountId, which prompts for an - // account ID for account hosts. - if useDeviceCode && authArguments.Host == "" && existingProfile != nil { - profileArguments := *authArguments - profileArguments.Host = existingProfile.Host - if err := validateDeviceCodeHost(ctx, &profileArguments); err != nil { + err = setHost(ctx, existingProfile, authArguments, args) + if err != nil { + return err + } + // Check the host once it is resolved, before setAccountID prompts for + // an account ID for account and unified hosts. + if useDeviceCode { + if err := validateDeviceCodeHost(authArguments); err != nil { return err } } - - err = setHostAndAccountId(ctx, existingProfile, authArguments, args) + err = setAccountID(ctx, existingProfile, authArguments) if err != nil { return err } @@ -536,18 +518,20 @@ func shouldPromptWorkspace(authArguments *auth.AuthArguments, existingProfile *p return true } +// setHostAndAccountId calls setHost and then setAccountID. +func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, authArguments *auth.AuthArguments, args []string) error { + if err := setHost(ctx, existingProfile, authArguments, args); err != nil { + return err + } + return setAccountID(ctx, existingProfile, authArguments) +} + // Sets the host in the persistentAuth object based on the provided arguments and flags. // Follows the following precedence: // 1. [HOST] (first positional argument) or --host flag. Error if both are specified. // 2. Profile host, if available. // 3. Prompt the user for the host. -// -// Set the account in the persistentAuth object based on the flags. -// Follows the following precedence: -// 1. --account-id flag. -// 2. account-id from the specified profile, if available. -// 3. Prompt the user for the account-id. -func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, authArguments *auth.AuthArguments, args []string) error { +func setHost(ctx context.Context, existingProfile *profile.Profile, authArguments *auth.AuthArguments, args []string) error { // If both [HOST] and --host are provided, return an error. host := authArguments.Host if len(args) > 0 && host != "" { @@ -597,7 +581,15 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, // .well-known/databricks-config endpoint. This is best-effort: failures // are logged as warnings and never block login. runHostDiscovery(ctx, authArguments) + return nil +} +// Set the account in the persistentAuth object based on the flags. +// Follows the following precedence: +// 1. --account-id flag. +// 2. account-id from the specified profile, if available. +// 3. Prompt the user for the account-id. +func setAccountID(ctx context.Context, existingProfile *profile.Profile, authArguments *auth.AuthArguments) error { if needsAccountIDPrompt(authArguments.Host, authArguments.DiscoveryURL) && authArguments.AccountID == "" { if existingProfile != nil && existingProfile.AccountID != "" { authArguments.AccountID = existingProfile.AccountID diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index d2b383650f2..6b7f3f8744c 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -28,39 +28,15 @@ var ( errDeviceCodeWorkspaceOnly = errors.New("--use-device-code is only supported for workspace hosts") ) -// validateDeviceCodeHost returns an error if the host is an account or unified -// host, or a workspace without device code login enabled. It checks the same -// signals that make setHostAndAccountId prompt for an account ID, so that it -// can run before the user is prompted for anything. -func validateDeviceCodeHost(ctx context.Context, authArguments *auth.AuthArguments) error { - // Work on a copy: host discovery fills in the account ID even for - // workspace hosts, which must not change the default profile name. - probe := *authArguments - params := auth.ExtractHostQueryParams(probe.Host) - probe.Host = params.Host - // Like setHostAndAccountId, use the account ID from the host for - // discovery, which needs it to resolve some unified hosts. - if probe.AccountID == "" { - probe.AccountID = params.AccountID - } - host := (&config.Config{Host: probe.Host}).CanonicalHostName() - if auth.IsClassicAccountHost(host) { - return errDeviceCodeWorkspaceOnly - } - runHostDiscovery(ctx, &probe) - if auth.HasUnifiedHostSignal(probe.DiscoveryURL) { +// validateDeviceCodeHost returns an error if the host resolved by setHost is +// an account or unified host. A workspace without device code login enabled +// is rejected by DeviceCodeChallenge before it creates a device code. +func validateDeviceCodeHost(authArguments *auth.AuthArguments) error { + if auth.IsClassicAccountHost((&config.Config{Host: authArguments.Host}).CanonicalHostName()) || + auth.HasUnifiedHostSignal(authArguments.DiscoveryURL) { return errDeviceCodeWorkspaceOnly } - arg, err := u2m.NewBasicWorkspaceOAuthArgument(host) - if err != nil { - return err - } - persistentAuth, err := u2m.NewPersistentAuth(ctx, u2m.WithOAuthArgument(arg)) - if err != nil { - return err - } - defer persistentAuth.Close() - return persistentAuth.CheckDeviceCodeSupported() + return nil } // validateDeviceCodeLogin returns an error if device code login is not enabled diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 2da44afbb46..84fdc3f0bbd 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -5,8 +5,6 @@ import ( "context" "errors" "io" - "net/http" - "net/http/httptest" "strings" "testing" "time" @@ -170,8 +168,11 @@ func TestFormatCode(t *testing.T) { } func TestValidateDeviceCodeHost(t *testing.T) { - workspace := newDeviceCodeWorkspaceServer(t, true) - notEnabled := newDeviceCodeWorkspaceServer(t, false) + workspace := newDiscoveryServer(t, map[string]any{ + "account_id": "acct-123", + "workspace_id": "12345", + "oidc_endpoint": "https://workspace.test/oidc", + }) unified := newDiscoveryServer(t, map[string]any{ "account_id": "acct-123", "oidc_endpoint": "https://spog.test/oidc/accounts/acct-123", @@ -189,7 +190,6 @@ func TestValidateDeviceCodeHost(t *testing.T) { }{ {name: "workspace host", args: auth.AuthArguments{Host: workspace.URL}}, {name: "workspace host with query params", args: auth.AuthArguments{Host: workspace.URL + "?o=12345"}}, - {name: "workspace without device code login", args: auth.AuthArguments{Host: notEnabled.URL}, wantErr: u2m.ErrDeviceCodeNotSupported}, {name: "classic account host", args: auth.AuthArguments{Host: "https://accounts.cloud.databricks.test"}, wantErr: errDeviceCodeWorkspaceOnly}, {name: "unified host", args: auth.AuthArguments{Host: unified.URL}, wantErr: errDeviceCodeWorkspaceOnly}, {name: "unified host with query params", args: auth.AuthArguments{Host: unified.URL + "?o=12345"}, wantErr: errDeviceCodeWorkspaceOnly}, @@ -199,33 +199,8 @@ func TestValidateDeviceCodeHost(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { args := tt.args - err := validateDeviceCodeHost(t.Context(), &args) - assert.ErrorIs(t, err, tt.wantErr) - // Discovery results must not leak into the arguments, where they - // would change the default profile name. - assert.Equal(t, tt.args, args) + require.NoError(t, setHost(t.Context(), nil, &args, nil)) + assert.ErrorIs(t, validateDeviceCodeHost(&args), tt.wantErr) }) } } - -// newDeviceCodeWorkspaceServer serves the host metadata and OIDC discovery -// documents of a workspace, optionally with device code login enabled. -func newDeviceCodeWorkspaceServer(t *testing.T, deviceCodeEnabled bool) *httptest.Server { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/.well-known/databricks-config": - _, _ = w.Write([]byte(`{"account_id": "acct-123", "workspace_id": "12345", "oidc_endpoint": "https://workspace.test/oidc"}`)) - case "/oidc/.well-known/oauth-authorization-server": - if deviceCodeEnabled { - _, _ = w.Write([]byte(`{"token_endpoint": "https://workspace.test/oidc/v1/token", "device_authorization_endpoint": "https://workspace.test/oidc/v1/device/auth"}`)) - } else { - _, _ = w.Write([]byte(`{"token_endpoint": "https://workspace.test/oidc/v1/token"}`)) - } - default: - w.WriteHeader(http.StatusNotFound) - } - })) - t.Cleanup(srv.Close) - return srv -} diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 0e4141ec911..657cacc3675 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -50,15 +50,6 @@ const maxConfirmationAttempts = 3 // the user can still enter, including this one. type ConfirmFunc func(ctx context.Context, da DeviceAuthorization, lastErr error, attemptsLeft int) (string, error) -// CheckDeviceCodeSupported returns the error DeviceCodeChallenge would fail -// with before contacting the device authorization endpoint, such as -// ErrDeviceCodeNotSupported. Callers use it to fail before prompting the user -// for anything. -func (a *PersistentAuth) CheckDeviceCodeSupported() error { - _, err := a.deviceCodeConfig() - return err -} - // deviceCodeConfig returns the OAuth config for device code login, or an // error if the host does not support it. func (a *PersistentAuth) deviceCodeConfig() (*oauth2.Config, error) { diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 087731d463b..19ecb45e887 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -355,20 +355,3 @@ func TestNormalizeCode(t *testing.T) { assert.Equal(t, tc.want, normalizeCode(tc.in), tc.in) } } - -func TestCheckDeviceCodeSupported(t *testing.T) { - tests := []struct { - name string - advertise bool - wantErr error - }{ - {name: "advertised", advertise: true}, - {name: "not advertised", wantErr: ErrDeviceCodeNotSupported}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - p := newDeviceCodeAuth(t, &fakeDeviceServer{}, tc.advertise) - assert.ErrorIs(t, p.CheckDeviceCodeSupported(), tc.wantErr) - }) - } -} From 388bc30e2af1ba843a846e9ef1742ce07391118a Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 12:54:34 +0000 Subject: [PATCH 20/24] Share OAuth error response handling and keep the cause oauthError rebuilt the error with %s, so errors.As could no longer find the *httpclient.HttpError or *oauth2.RetrieveError behind an error from DeviceCodeChallenge. Return an error type that shows the same message and unwraps to the original error. refresh() parsed the same RFC 6749 error response inline, with the same message format and the same %s issue. Use the shared helpers there too, and move them to error.go now that both login flows and the refresh use them. An error response that is not JSON now returns the original error instead of the JSON parsing error. Co-authored-by: Isaac --- libs/auth/u2m/device_code.go | 34 ---------------- libs/auth/u2m/device_code_test.go | 25 ++++++++++++ libs/auth/u2m/error.go | 58 ++++++++++++++++++++++++++- libs/auth/u2m/persistent_auth.go | 37 +++-------------- libs/auth/u2m/persistent_auth_test.go | 3 ++ 5 files changed, 90 insertions(+), 67 deletions(-) diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 657cacc3675..e4db9f72940 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/databricks/databricks-sdk-go/httpclient" "golang.org/x/oauth2" ) @@ -224,36 +223,3 @@ func (a *PersistentAuth) postForm(ctx context.Context, endpoint string, form url func normalizeCode(code string) string { return strings.NewReplacer("-", "", " ", "").Replace(strings.ToUpper(strings.TrimSpace(code))) } - -// oauthError extracts the RFC 6749 §5.2 error response from err so that users -// see the server's explanation instead of a raw HTTP response body. -func oauthError(err error) error { - _, code, description := oauthErrorResponse(err) - if code == "" { - return err - } - return fmt.Errorf("%s (error code: %s)", description, code) -} - -// oauthErrorResponse returns the HTTP status code, and the error code and -// description of the RFC 6749 §5.2 error response carried by err. The status -// is 0 if err does not carry an HTTP response, and the code is empty if err -// does not carry an error response. -func oauthErrorResponse(err error) (status int, code, description string) { - // The default RoundTripper of httpclient.ApiClient returns an error for - // non-2xx responses, so the oauth2 library never parses the body itself. - if httpErr, ok := errors.AsType[*httpclient.HttpError](err); ok { - var resp struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - if json.Unmarshal([]byte(httpErr.Message), &resp) != nil { - return httpErr.StatusCode, "", "" - } - return httpErr.StatusCode, resp.Error, resp.ErrorDescription - } - if retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err); ok { - return retrieveErr.Response.StatusCode, retrieveErr.ErrorCode, retrieveErr.ErrorDescription - } - return 0, "", "" -} diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 19ecb45e887..2f3b8dc1d9c 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -10,8 +10,10 @@ import ( "testing" "time" + "github.com/databricks/databricks-sdk-go/httpclient" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" ) const ( @@ -196,6 +198,29 @@ func TestDeviceCodeChallenge_Errors(t *testing.T) { } } +func TestDeviceCodeChallenge_ErrorKeepsHTTPError(t *testing.T) { + f := &fakeDeviceServer{ + expiresIn: 600, + tokenStatus: http.StatusBadRequest, + tokenBody: `{"error": "invalid_client", "error_description": "Client is not allowed to use device code"}`, + } + confirm := func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + return testConfirmationCode, nil + } + + _, err := newDeviceCodeAuth(t, f, true).DeviceCodeChallenge(t.Context(), confirm) + httpErr, ok := errors.AsType[*httpclient.HttpError](err) + require.True(t, ok, "want *httpclient.HttpError in %v", err) + assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode) + + // A plain HTTP client hands the error response to the CLI as is. + _, err = newDeviceCodeAuth(t, f, true, WithHttpClient(&http.Client{})).DeviceCodeChallenge(t.Context(), confirm) + retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err) + require.True(t, ok, "want *oauth2.RetrieveError in %v", err) + assert.Equal(t, http.StatusBadRequest, retrieveErr.Response.StatusCode) + assert.EqualError(t, err, "token exchange: Client is not allowed to use device code (error code: invalid_client)") +} + func TestDeviceCodeChallenge_RetriesWrongConfirmationCode(t *testing.T) { f := &fakeDeviceServer{expiresIn: 600} p := newDeviceCodeAuth(t, f, true) diff --git a/libs/auth/u2m/error.go b/libs/auth/u2m/error.go index 51ba9036551..d8469409fb3 100644 --- a/libs/auth/u2m/error.go +++ b/libs/auth/u2m/error.go @@ -1,6 +1,13 @@ package u2m -import "errors" +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/databricks/databricks-sdk-go/httpclient" + "golang.org/x/oauth2" +) // ErrMissingRefreshToken is returned when a token refresh is requested but the // cached OAuth token does not include a refresh token. @@ -12,3 +19,52 @@ var ErrMissingRefreshToken = errors.New("cached token has no refresh token") type InvalidRefreshTokenError struct { error } + +// oauthError extracts the RFC 6749 §5.2 error response from err so that users +// see the server's explanation instead of a raw HTTP response body. +func oauthError(err error) error { + _, code, description := oauthErrorResponse(err) + if code == "" { + return err + } + return &oauthResponseError{code: code, description: description, err: err} +} + +// oauthResponseError shows an RFC 6749 §5.2 error response and keeps the +// error that carried it, so that callers can still inspect the HTTP response. +type oauthResponseError struct { + code string + description string + err error +} + +func (e *oauthResponseError) Error() string { + return fmt.Sprintf("%s (error code: %s)", e.description, e.code) +} + +func (e *oauthResponseError) Unwrap() error { + return e.err +} + +// oauthErrorResponse returns the HTTP status code, and the error code and +// description of the RFC 6749 §5.2 error response carried by err. The status +// is 0 if err does not carry an HTTP response, and the code is empty if err +// does not carry an error response. +func oauthErrorResponse(err error) (status int, code, description string) { + // The default RoundTripper of httpclient.ApiClient returns an error for + // non-2xx responses, so the oauth2 library never parses the body itself. + if httpErr, ok := errors.AsType[*httpclient.HttpError](err); ok { + var resp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if json.Unmarshal([]byte(httpErr.Message), &resp) != nil { + return httpErr.StatusCode, "", "" + } + return httpErr.StatusCode, resp.Error, resp.ErrorDescription + } + if retrieveErr, ok := errors.AsType[*oauth2.RetrieveError](err); ok { + return retrieveErr.Response.StatusCode, retrieveErr.ErrorCode, retrieveErr.ErrorDescription + } + return 0, "", "" +} diff --git a/libs/auth/u2m/persistent_auth.go b/libs/auth/u2m/persistent_auth.go index 58986cc9c80..de0df97d47e 100644 --- a/libs/auth/u2m/persistent_auth.go +++ b/libs/auth/u2m/persistent_auth.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" - "encoding/json" "errors" "fmt" "net" @@ -426,38 +425,12 @@ func (a *PersistentAuth) refresh(oldToken *oauth2.Token) (*oauth2.Token, error) expired.Expiry = time.Now().Add(-time.Minute) t, err := cfg.TokenSource(ctx, &expired).Token() if err != nil { - // The default RoundTripper of our httpclient.ApiClient returns an error - // if the response status code is not 2xx. This isn't compliant with the - // RoundTripper interface, so this error isn't handled by the oauth2 - // library. We need to handle it here. - if internalHttpError, ok := errors.AsType[*httpclient.HttpError](err); ok { - // error fields - // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 - var errResponse struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - if unmarshalErr := json.Unmarshal([]byte(internalHttpError.Message), &errResponse); unmarshalErr != nil { - return nil, fmt.Errorf("unmarshal: %w", unmarshalErr) - } - // Invalid refresh tokens get their own error type so they can be - // better presented to users. - if errResponse.ErrorDescription == "Refresh token is invalid" { - return nil, &InvalidRefreshTokenError{err} - } - return nil, fmt.Errorf("%s (error code: %s)", errResponse.ErrorDescription, errResponse.Error) + // Invalid refresh tokens get their own error type so they can be + // better presented to users. + if _, _, description := oauthErrorResponse(err); description == "Refresh token is invalid" { + return nil, &InvalidRefreshTokenError{err} } - - // Handle responses from well-behaved *http.Client implementations. - if httpErr, ok := errors.AsType[*oauth2.RetrieveError](err); ok { - // Invalid refresh tokens get their own error type so they can be - // better presented to users. - if httpErr.ErrorDescription == "Refresh token is invalid" { - return nil, &InvalidRefreshTokenError{err} - } - return nil, fmt.Errorf("%s (error code: %s)", httpErr.ErrorDescription, httpErr.ErrorCode) - } - return nil, err + return nil, oauthError(err) } err = a.store.Put(a.oAuthArgument.GetCacheKey(), storage.Entry{Token: t}) if err != nil { diff --git a/libs/auth/u2m/persistent_auth_test.go b/libs/auth/u2m/persistent_auth_test.go index 831fac8402c..6473092c5f9 100644 --- a/libs/auth/u2m/persistent_auth_test.go +++ b/libs/auth/u2m/persistent_auth_test.go @@ -1007,6 +1007,9 @@ func TestForceRefreshToken_FailsOnRefreshError(t *testing.T) { if got := err.Error(); got != want { t.Errorf("ForceRefreshToken(): want error %q, got %q", want, got) } + if _, ok := errors.AsType[*oauth2.RetrieveError](err); !ok { + t.Errorf("ForceRefreshToken(): want error wrapping *oauth2.RetrieveError, got %v", err) + } } func TestForceRefreshToken_InvalidRefreshTokenError(t *testing.T) { From 5d31ef5aa73b5884f4d4cfa9d41bbb1d9b324a98 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 13:08:58 +0000 Subject: [PATCH 21/24] Do not retry an unknown device code The workspace answers an unknown device code, e.g. one it discarded after the login expired, with HTTP 400 and invalid_request like a wrong confirmation code. Entering the code again cannot succeed, so fail right away. Only the error description tells the two apart; if it changes, the CLI falls back to asking for the code again. Co-authored-by: Isaac --- cmd/auth/README.md | 4 +++- docs/device-code-login.md | 2 +- libs/auth/u2m/device_code.go | 13 +++++++++++-- libs/auth/u2m/device_code_test.go | 10 ++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/cmd/auth/README.md b/cmd/auth/README.md index cf53ff9c873..11d43bc14c4 100644 --- a/cmd/auth/README.md +++ b/cmd/auth/README.md @@ -95,7 +95,9 @@ Notes on the server behavior the CLI relies on: form parameter. A wrong confirmation code does not consume the device code; a successful exchange does. The server answers a wrong confirmation code with HTTP 400 and `invalid_request`, and the CLI lets the user enter it up - to 3 times in total. Any other error ends the login. + to 3 times in total. Any other error ends the login, including an unknown + device code, which the server answers the same way but with the + description `Invalid device code`. - Approving the login in the browser extends the device code's lifetime, so the CLI does not enforce the `expires_in` from the device authorization response. diff --git a/docs/device-code-login.md b/docs/device-code-login.md index b4b18278e2d..87c96c03c16 100644 --- a/docs/device-code-login.md +++ b/docs/device-code-login.md @@ -61,7 +61,7 @@ fail with a token refresh error and you need to run the login again. | `--use-device-code is only supported for workspace hosts` | The host is an account or unified host. | Log in to a workspace host instead. | | The browser says the code is invalid or expired | The code was mistyped, or more than ~10 minutes passed. | Re-enter the code carefully, or run the login again for a new code. Codes never contain `0`, `1`, `2`, `O`, `Z`, or `I`. | | `Invalid confirmation code` | The confirmation code was mistyped or left blank. | Re-enter the code. After 3 rejected codes, the login fails with `Invalid confirmation code (error code: invalid_request). Start login from scratch` and you need to run it again. | -| `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. | Re-entering the code does not help. Run the login again to get a new code. | +| `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. | diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index e4db9f72940..2b106e0488e 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -23,6 +23,11 @@ const confirmationCodeParam = "confirmation_code" // deviceCodeGrantType is the grant type of the RFC 8628 token request. const deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" +// invalidDeviceCodeDescription is the error_description the server sends with +// HTTP 400 and invalid_request for a device code it does not know, e.g. one +// it discarded after the login expired. +const invalidDeviceCodeDescription = "Invalid device code" + // ErrDeviceCodeNotSupported is returned when the workspace does not advertise // a device authorization endpoint in its OIDC discovery document. var ErrDeviceCodeNotSupported = errors.New("device code login is not enabled for this workspace") @@ -107,8 +112,12 @@ func (a *PersistentAuth) DeviceCodeChallenge(ctx context.Context, confirm Confir // 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. - if status, errCode, _ := oauthErrorResponse(err); status != http.StatusBadRequest || errCode != "invalid_request" { + // 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 { return nil, fmt.Errorf("token exchange: %w", lastErr) } } diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index 2f3b8dc1d9c..c155ce57ebd 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -185,6 +185,14 @@ func TestDeviceCodeChallenge_Errors(t *testing.T) { }, wantErr: "token exchange: Device grant rate limit exceeded (error code: invalid_request)", }, + { + name: "unknown device code", + server: &fakeDeviceServer{ + tokenStatus: http.StatusBadRequest, + tokenBody: `{"error": "invalid_request", "error_description": "Invalid device code"}`, + }, + wantErr: "token exchange: Invalid device code (error code: invalid_request)", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -194,6 +202,8 @@ func TestDeviceCodeChallenge_Errors(t *testing.T) { return testConfirmationCode, nil }) assert.EqualError(t, err, tc.wantErr) + // None of these errors asks for the confirmation code again. + assert.LessOrEqual(t, tc.server.tokenRequests.Load(), int32(1)) }) } } From 7eb52b59572e8422b7c8f396aaabfc1c1bc1c677 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 14:05:27 +0000 Subject: [PATCH 22/24] Test refresh errors without an OAuth error code Pin what refresh() returns through the default ApiClient transport for an error response that is not JSON or has no error field. Before the error handling was shared, these gave a JSON parsing error and an empty "(error code: )" message. Co-authored-by: Isaac --- libs/auth/u2m/persistent_auth_test.go | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/libs/auth/u2m/persistent_auth_test.go b/libs/auth/u2m/persistent_auth_test.go index 6473092c5f9..1817ff778ae 100644 --- a/libs/auth/u2m/persistent_auth_test.go +++ b/libs/auth/u2m/persistent_auth_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/databricks/cli/libs/auth/storage" + "github.com/databricks/databricks-sdk-go/httpclient" "github.com/databricks/databricks-sdk-go/httpclient/fixtures" "golang.org/x/oauth2" ) @@ -572,6 +573,39 @@ func TestToken_ReturnsInvalidRefreshTokenError(t *testing.T) { } } +func TestToken_ReturnsErrorWithoutErrorCode(t *testing.T) { + // The default ApiClient transport returns an error response as an + // *httpclient.HttpError, which is returned as is without an error code. + for _, body := range []string{"Bad Gateway", `{"error_description": "Bad Gateway"}`} { + t.Run(body, func(t *testing.T) { + arg, err := NewBasicAccountOAuthArgument("https://accounts.cloud.databricks.test", "xyz") + if err != nil { + t.Fatalf("NewBasicAccountOAuthArgument(): want no error, got %v", err) + } + p, err := NewPersistentAuth( + t.Context(), + WithTokenStore(&tokenStoreMock{lookup: func(string) (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "expired", RefreshToken: "cde", Expiry: time.Now().Add(-time.Minute)}, nil + }}), + WithHttpClient(&http.Client{Transport: httpclient.NewApiClient(httpclient.ClientConfig{ + Transport: fixtures.SliceTransport{{Method: "POST", Resource: "/oidc/accounts/xyz/v1/token", Response: body, Status: 502}}, + })}), + WithOAuthEndpointSupplier(MockOAuthEndpointSupplier{}), + WithOAuthArgument(arg), + ) + if err != nil { + t.Fatalf("NewPersistentAuth(): want no error, got %v", err) + } + defer p.Close() + _, err = p.Token() + want := `token refresh: Post "https://accounts.cloud.databricks.test/oidc/accounts/xyz/v1/token": http 502: ` + body + if err == nil || err.Error() != want { + t.Errorf("p.Token(): want error %q, got %v", want, err) + } + }) + } +} + func TestToken_ReturnsMissingRefreshTokenErrorWhenExpired(t *testing.T) { c := &tokenStoreMock{ lookup: func(key string) (*oauth2.Token, error) { From 9f8fe2dc2c94265f25949110e93d15b91eed2a84 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 14:07:21 +0000 Subject: [PATCH 23/24] Reject an incomplete device authorization response A 200 response without verification_uri or user_code made the CLI print empty instructions and then wait for a confirmation code the user had no way to get, and a missing device_code was sent as device_code=. Fail before asking for the confirmation code if any of the three is empty. Co-authored-by: Isaac --- libs/auth/u2m/device_code.go | 10 ++++++++++ libs/auth/u2m/device_code_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 2b106e0488e..51f77f8e189 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -139,6 +139,16 @@ func (a *PersistentAuth) requestDeviceAuthorization(ctx context.Context, cfg *oa if err := json.Unmarshal(body, &da); err != nil { return nil, fmt.Errorf("parsing device authorization response: %w", err) } + // The user needs user_code and verification_uri to approve the login, and + // the token request needs device_code. + switch { + case da.DeviceCode == "": + return nil, errors.New("server response missing device_code") + case da.UserCode == "": + return nil, errors.New("server response missing user_code") + case da.VerificationURI == "": + return nil, errors.New("server response missing verification_uri") + } return &da, nil } diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index c155ce57ebd..f976ef5555d 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -208,6 +208,31 @@ func TestDeviceCodeChallenge_Errors(t *testing.T) { } } +func TestDeviceCodeChallenge_IncompleteDeviceAuthorization(t *testing.T) { + for _, missing := range []string{"device_code", "user_code", "verification_uri"} { + t.Run(missing, func(t *testing.T) { + resp := map[string]any{ + "device_code": testDeviceCode, + "user_code": testUserCode, + "verification_uri": "https://workspace.test/device/verification", + "expires_in": 600, + } + delete(resp, missing) + body, err := json.Marshal(resp) + require.NoError(t, err) + f := &fakeDeviceServer{deviceAuthStatus: http.StatusOK, deviceAuthBody: string(body)} + p := newDeviceCodeAuth(t, f, true) + + _, err = p.DeviceCodeChallenge(t.Context(), func(_ context.Context, _ DeviceAuthorization, _ error, _ int) (string, error) { + t.Fatal("confirm must not be called") + return "", nil + }) + assert.EqualError(t, err, "device authorization: server response missing "+missing) + assert.Equal(t, int32(0), f.tokenRequests.Load()) + }) + } +} + func TestDeviceCodeChallenge_ErrorKeepsHTTPError(t *testing.T) { f := &fakeDeviceServer{ expiresIn: 600, From 419aeb61132ed4a26e8834c9936101d2993f6d88 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 23 Oct 2026 14:12:43 +0000 Subject: [PATCH 24/24] Share the account or unified host check with device code login validateDeviceCodeHost repeated the check in needsAccountIDPrompt. Move it into isAccountOrUnifiedHost and call it from both, so that the two cannot drift apart. Co-authored-by: Isaac --- cmd/auth/login.go | 7 +++++++ cmd/auth/login_device_code.go | 4 +--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 5114ab8f78f..13a5a8a61e0 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -609,6 +609,13 @@ func setAccountID(ctx context.Context, existingProfile *profile.Profile, authArg // for OAuth URL construction. True for classic account hosts (accounts.*) and // for unified hosts detected via account-scoped DiscoveryURL. func needsAccountIDPrompt(host, discoveryURL string) bool { + return isAccountOrUnifiedHost(host, discoveryURL) +} + +// isAccountOrUnifiedHost reports whether host is a classic account host +// (accounts.* or accounts-dod.*) or a unified host with an account-scoped +// discoveryURL. +func isAccountOrUnifiedHost(host, discoveryURL string) bool { canonicalHost := (&config.Config{Host: host}).CanonicalHostName() if auth.IsClassicAccountHost(canonicalHost) { return true diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index 6b7f3f8744c..93455ad9dd7 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -10,7 +10,6 @@ import ( "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/env" - "github.com/databricks/databricks-sdk-go/config" "github.com/spf13/cobra" ) @@ -32,8 +31,7 @@ var ( // an account or unified host. A workspace without device code login enabled // is rejected by DeviceCodeChallenge before it creates a device code. func validateDeviceCodeHost(authArguments *auth.AuthArguments) error { - if auth.IsClassicAccountHost((&config.Config{Host: authArguments.Host}).CanonicalHostName()) || - auth.HasUnifiedHostSignal(authArguments.DiscoveryURL) { + if isAccountOrUnifiedHost(authArguments.Host, authArguments.DiscoveryURL) { return errDeviceCodeWorkspaceOnly } return nil