diff --git a/acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg b/acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg deleted file mode 100644 index 02b3737c466..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/out.databrickscfg +++ /dev/null @@ -1,16 +0,0 @@ -; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. -[DEFAULT] - -[test] -host = [DATABRICKS_URL] -workspace_id = [WORKSPACE_ID] -auth_type = databricks-cli - -[__settings__] -default_profile = test - -[scoped] -host = [DATABRICKS_URL] -workspace_id = [WORKSPACE_ID] -scopes = sql -auth_type = databricks-cli diff --git a/acceptance/cmd/auth/login/device-code/nominal/out.test.toml b/acceptance/cmd/auth/login/device-code/nominal/out.test.toml deleted file mode 100644 index 18b1a88417e..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/out.test.toml +++ /dev/null @@ -1 +0,0 @@ -Cloud = false diff --git a/acceptance/cmd/auth/login/device-code/nominal/output.txt b/acceptance/cmd/auth/login/device-code/nominal/output.txt deleted file mode 100644 index 2216533b304..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/output.txt +++ /dev/null @@ -1,56 +0,0 @@ - -=== Login with a host - ->>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code -Using a browser on another device, visit: -[DATABRICKS_URL]/device/verification - -And enter the code: -WDJB-MJHT - -Once finished, enter the confirmation code provided in your browser. -Confirmation code: Profile test was successfully saved - ->>> [CLI] auth token --profile test -json.access_token = "oauth-token"; - ->>> [CLI] auth profiles -Name Host Valid -test (Default) [DATABRICKS_URL] YES - -=== Login again with the host from the saved profile - ->>> [CLI] auth login --profile test --use-device-code -Using a browser on another device, visit: -[DATABRICKS_URL]/device/verification - -And enter the code: -WDJB-MJHT - -Once finished, enter the confirmation code provided in your browser. -Confirmation code: Profile test was successfully saved - ->>> [CLI] auth token --profile test -json.access_token = "oauth-token"; - ->>> print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token -{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+all-apis"} -{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} -{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+all-apis"} -{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} - -=== Login with custom scopes - ->>> [CLI] auth login --host [DATABRICKS_URL] --profile scoped --use-device-code --scopes sql -Using a browser on another device, visit: -[DATABRICKS_URL]/device/verification - -And enter the code: -WDJB-MJHT - -Once finished, enter the confirmation code provided in your browser. -Confirmation code: Profile scoped was successfully saved - ->>> print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token -{"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+sql"} -{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-3&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} diff --git a/acceptance/cmd/auth/login/device-code/nominal/script b/acceptance/cmd/auth/login/device-code/nominal/script deleted file mode 100644 index f1d6c04f59a..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/script +++ /dev/null @@ -1,20 +0,0 @@ -sethome "./home" - -title "Login with a host\n" -printf "FTFA-QQKY\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code -trace $CLI auth token --profile test | gron.py | grep access_token -trace $CLI auth profiles - -title "Login again with the host from the saved profile\n" -printf "FTFA-QQKY\n" | trace $CLI auth login --profile test --use-device-code -trace $CLI auth token --profile test | gron.py | grep access_token -trace print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token - -# The token gets the scopes requested at device authorization, so --scopes is -# sent there and not with the token request. -title "Login with custom scopes\n" -printf "FTFA-QQKY\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile scoped --use-device-code --scopes sql -trace print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token - -# Track the .databrickscfg file that was created to surface changes. -mv "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/login/device-code/nominal/test.toml b/acceptance/cmd/auth/login/device-code/nominal/test.toml deleted file mode 100644 index 159efe02696..00000000000 --- a/acceptance/cmd/auth/login/device-code/nominal/test.toml +++ /dev/null @@ -1 +0,0 @@ -RecordRequests = true diff --git a/acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml b/acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml deleted file mode 100644 index 18b1a88417e..00000000000 --- a/acceptance/cmd/auth/login/device-code/wrong-code/out.test.toml +++ /dev/null @@ -1 +0,0 @@ -Cloud = false diff --git a/acceptance/cmd/auth/login/device-code/wrong-code/output.txt b/acceptance/cmd/auth/login/device-code/wrong-code/output.txt deleted file mode 100644 index 2ba7f3ea23e..00000000000 --- a/acceptance/cmd/auth/login/device-code/wrong-code/output.txt +++ /dev/null @@ -1,40 +0,0 @@ - -=== Login fails when no confirmation code is provided - ->>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code -Using a browser on another device, visit: -[DATABRICKS_URL]/device/verification - -And enter the code: -WDJB-MJHT - -Once finished, enter the confirmation code provided in your browser. -Confirmation code: Error: reading confirmation code: EOF - -=== Login fails after 3 wrong confirmation codes - ->>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code -Using a browser on another device, visit: -[DATABRICKS_URL]/device/verification - -And enter the code: -WDJB-MJHT - -Once finished, enter the confirmation code provided in your browser. -Confirmation code: Invalid confirmation code (error code: invalid_request). Enter the confirmation code again (2 attempts left). -Confirmation code: Invalid confirmation code (error code: invalid_request). Enter the confirmation code again (1 attempt left). -Confirmation code: Error: Invalid confirmation code (error code: invalid_request). Start login from scratch - -=== Login succeeds after re-entering the confirmation code - ->>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code -Using a browser on another device, visit: -[DATABRICKS_URL]/device/verification - -And enter the code: -WDJB-MJHT - -Once finished, enter the confirmation code provided in your browser. -Confirmation code: Invalid confirmation code (error code: invalid_request). Enter the confirmation code again (2 attempts left). -Confirmation code: Invalid confirmation code (error code: invalid_request). Enter the confirmation code again (1 attempt left). -Confirmation code: Profile test was successfully saved diff --git a/acceptance/cmd/auth/login/device-code/wrong-code/script b/acceptance/cmd/auth/login/device-code/wrong-code/script deleted file mode 100644 index 3adb0de81a3..00000000000 --- a/acceptance/cmd/auth/login/device-code/wrong-code/script +++ /dev/null @@ -1,13 +0,0 @@ -sethome "./home" - -title "Login fails when no confirmation code is provided\n" -trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code < /dev/null - -title "Login fails after 3 wrong confirmation codes\n" -printf "AAAA-AAAA\nBBBB-BBBB\nCCCC-CCCC\n" | trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code - -# A wrong confirmation code leaves the device code valid, so the user can -# enter it again. A blank line counts as a wrong confirmation code. -# The confirmation code is accepted the way the browser displays it, in any case. -title "Login succeeds after re-entering the confirmation code\n" -printf "AAAA-AAAA\n\nftfa-qqky\n" | trace $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index d38d2543b7c..a95f5a7d2da 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -3,13 +3,19 @@ package auth import ( "bytes" "context" + "encoding/json" "errors" "io" + "net/url" + "os" + "path/filepath" "runtime" + "strconv" "strings" "testing" "time" + "github.com/databricks/cli/internal/testutil" "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/cmdio" @@ -17,6 +23,7 @@ import ( "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/testserver" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -211,6 +218,7 @@ func TestIsDeviceCodeSupportedForHost(t *testing.T) { // startDeviceCodeLogin runs "auth login --use-device-code" with args in a // terminal emulator, using config as the contents of .databrickscfg. func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest.Term[struct{}] { + testutil.CleanupEnvironment(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) @@ -219,7 +227,9 @@ func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest t.Setenv(deviceCodeEnvVar, "true") return termtest.Run(t, func(ctx context.Context) (struct{}, error) { - cmd := newLoginCommand(&auth.AuthArguments{}) + authArguments := &auth.AuthArguments{} + cmd := newLoginCommand(authArguments) + cmd.Flags().StringVar(&authArguments.Host, "host", "", "") cmd.Flags().String("profile", "", "") cmd.SetContext(ctx) cmd.SetArgs(append([]string{"--use-device-code"}, args...)) @@ -304,3 +314,86 @@ resources = https://workspace.test/ai-gateway/mcp-services/system.ai.github _, err := tm.Result() assert.EqualError(t, err, "^C") } + +// newDeviceCodeServer returns a fake workspace with device code login. +func newDeviceCodeServer(t *testing.T) *testserver.Server { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + return server +} + +// A wrong or blank confirmation code leaves the device code valid, so the user +// can enter it again. The code is accepted the way the browser displays it, in +// any case. +func TestLoginDeviceCodeReadsConfirmationCode(t *testing.T) { + server := newDeviceCodeServer(t) + tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test") + tm.WaitFor("Confirmation code") + tm.Type("AAAA-AAAA\n") + tm.WaitFor("Enter the confirmation code again (2 attempts left)") + tm.Type("\n") + tm.WaitFor("Enter the confirmation code again (1 attempt left)") + tm.Type("ftfa-qqky\n") + _, err := tm.Result() + require.NoError(t, err) + + p, err := loadProfileByName(t.Context(), "test", profile.DefaultProfiler) + require.NoError(t, err) + require.NotNil(t, p) + assert.Equal(t, server.URL, p.Host) + assert.Equal(t, strconv.Itoa(testserver.TestWorkspaceID), p.WorkspaceID) + assert.Equal(t, authTypeDatabricksCLI, p.AuthType) + + raw, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".databricks", "token-cache.json")) + require.NoError(t, err) + var cache struct { + Tokens map[string]json.RawMessage `json:"tokens"` + } + require.NoError(t, json.Unmarshal(raw, &cache)) + assert.Contains(t, cache.Tokens, "test") +} + +func TestLoginDeviceCodeFailsAfterWrongConfirmationCodes(t *testing.T) { + server := newDeviceCodeServer(t) + tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test") + tm.WaitFor("Confirmation code") + tm.Type("AAAA-AAAA\nBBBB-BBBB\nCCCC-CCCC\n") + _, err := tm.Result() + assert.EqualError(t, err, "Invalid confirmation code (error code: invalid_request). Start login from scratch") +} + +func TestLoginDeviceCodeFailsWithoutConfirmationCode(t *testing.T) { + server := newDeviceCodeServer(t) + tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test") + tm.WaitFor("Confirmation code") + tm.Close() + _, err := tm.Result() + assert.EqualError(t, err, "reading confirmation code: EOF") +} + +// The token gets the scopes requested at device authorization, so --scopes is +// sent there and not with the token request. +func TestLoginDeviceCodeRequestsScopes(t *testing.T) { + server := newDeviceCodeServer(t) + var scope string + server.RequestCallback = func(r *testserver.Request) { + if r.URL.Path == "/oidc/v1/device/auth" { + form, err := url.ParseQuery(string(r.Body)) + assert.NoError(t, err) + scope = form.Get("scope") + } + } + tm := startDeviceCodeLogin(t, "", "--host", server.URL, "--profile", "test", "--scopes", "sql") + tm.WaitFor("Confirmation code") + tm.Type("FTFA-QQKY\n") + _, err := tm.Result() + require.NoError(t, err) + assert.Equal(t, "offline_access sql", scope) + + p, err := loadProfileByName(t.Context(), "test", profile.DefaultProfiler) + require.NoError(t, err) + require.NotNil(t, p) + assert.Equal(t, server.URL, p.Host) + assert.Equal(t, strconv.Itoa(testserver.TestWorkspaceID), p.WorkspaceID) + assert.Equal(t, "sql", p.Scopes) +} diff --git a/libs/auth/u2m/device_code.go b/libs/auth/u2m/device_code.go index 51f77f8e189..fc7983f3b24 100644 --- a/libs/auth/u2m/device_code.go +++ b/libs/auth/u2m/device_code.go @@ -32,14 +32,27 @@ const invalidDeviceCodeDescription = "Invalid device code" // a device authorization endpoint in its OIDC discovery document. var ErrDeviceCodeNotSupported = errors.New("device code login is not enabled for this workspace") +// ErrInvalidConfirmationCode matches the token exchange error for a +// confirmation code the server rejects. The device code stays valid, so the +// exchange can be retried with the right code. +var ErrInvalidConfirmationCode = errors.New("invalid confirmation code") + +// ErrInvalidDeviceCode matches the token exchange error for a device code the +// server does not know, e.g. one it discarded after the login expired. The +// login has to be started again. +var ErrInvalidDeviceCode = errors.New("invalid device code") + // DeviceAuthorization holds what the user needs to approve a device code -// login from another device. +// login from another device, and the device code to exchange afterwards. type DeviceAuthorization struct { // UserCode is the code the user enters at VerificationURI. UserCode string // VerificationURI is the page where the user approves the login. VerificationURI string + + // DeviceCode identifies the login in the token exchange. + DeviceCode string } // maxConfirmationAttempts is how many confirmation codes the user can enter @@ -90,44 +103,117 @@ func (a *PersistentAuth) DeviceCodeChallenge(ctx context.Context, confirm Confir da, err := a.requestDeviceAuthorization(ctx, cfg) if err != nil { - return nil, fmt.Errorf("device authorization: %w", oauthError(err)) + return nil, err } - authorization := DeviceAuthorization{ - UserCode: da.UserCode, - VerificationURI: da.VerificationURI, - } var lastErr error for attempt := range maxConfirmationAttempts { - code, err := confirm(ctx, authorization, lastErr, maxConfirmationAttempts-attempt) + code, err := confirm(ctx, *da, lastErr, maxConfirmationAttempts-attempt) if err != nil { return nil, err } - t, err := a.exchangeDeviceCode(ctx, cfg, da.DeviceCode, normalizeCode(code)) + t, err := a.exchangeConfirmationCode(ctx, cfg, da.DeviceCode, code) if err == nil { return t, nil } - lastErr = oauthError(err) - // The server answers a wrong confirmation code with HTTP 400 and - // invalid_request. Any other error cannot be fixed by entering the - // code again, including invalid_request with HTTP 429 when the server - // rate limits the token requests for this device code. An unknown - // device code also gets HTTP 400 and invalid_request, and only the - // description tells it apart. If the description changes, the user is - // asked for the code again until the attempts run out. - status, errCode, description := oauthErrorResponse(err) - if status != http.StatusBadRequest || errCode != "invalid_request" || description == invalidDeviceCodeDescription { + lastErr = err + if !errors.Is(err, ErrInvalidConfirmationCode) { return nil, fmt.Errorf("token exchange: %w", lastErr) } } return nil, fmt.Errorf("%w. Start login from scratch", lastErr) } -// requestDeviceAuthorization makes the RFC 8628 device authorization request. +// RequestDeviceAuthorization starts a device code login by making the RFC +// 8628 device authorization request. The caller shows the user code and the +// verification URI to the user and later passes the device code to +// ExchangeDeviceCode. Only workspace hosts are supported. +func (a *PersistentAuth) RequestDeviceAuthorization(ctx context.Context) (*DeviceAuthorization, error) { + cfg, err := a.deviceCodeConfig() + if err != nil { + return nil, err + } + return a.requestDeviceAuthorization(ctx, cfg) +} + +// ExchangeDeviceCode completes a device code login started by +// RequestDeviceAuthorization with a single token request, without retries. +// The confirmation code is accepted the way the browser displays it. An +// error matches ErrInvalidConfirmationCode or ErrInvalidDeviceCode when the +// server rejects one of the codes. The caller is responsible for storing the +// returned token. +func (a *PersistentAuth) ExchangeDeviceCode(ctx context.Context, deviceCode, confirmationCode string) (*oauth2.Token, error) { + cfg, err := a.deviceCodeConfig() + if err != nil { + return nil, err + } + t, err := a.exchangeConfirmationCode(ctx, cfg, deviceCode, confirmationCode) + if err != nil { + return nil, fmt.Errorf("token exchange: %w", err) + } + return t, nil +} + +// requestDeviceAuthorization makes the RFC 8628 device authorization request +// and returns what the user needs to approve the login. +func (a *PersistentAuth) requestDeviceAuthorization(ctx context.Context, cfg *oauth2.Config) (*DeviceAuthorization, error) { + da, err := a.postDeviceAuthorization(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("device authorization: %w", oauthError(err)) + } + return &DeviceAuthorization{ + UserCode: da.UserCode, + VerificationURI: da.VerificationURI, + DeviceCode: da.DeviceCode, + }, nil +} + +// exchangeConfirmationCode exchanges the device code and the confirmation +// code for a token, and marks the errors for a rejected code. +func (a *PersistentAuth) exchangeConfirmationCode(ctx context.Context, cfg *oauth2.Config, deviceCode, confirmationCode string) (*oauth2.Token, error) { + t, err := a.exchangeDeviceCode(ctx, cfg, deviceCode, NormalizeCode(confirmationCode)) + if err == nil { + return t, nil + } + // The server answers a wrong confirmation code with HTTP 400 and + // invalid_request. Any other error cannot be fixed by entering the + // code again, including invalid_request with HTTP 429 when the server + // rate limits the token requests for this device code. An unknown + // device code also gets HTTP 400 and invalid_request, and only the + // description tells it apart. If the description changes, the error is + // reported as a wrong confirmation code. + status, errCode, description := oauthErrorResponse(err) + err = oauthError(err) + switch { + case status != http.StatusBadRequest || errCode != "invalid_request": + return nil, err + case description == invalidDeviceCodeDescription: + return nil, &codeRejectedError{err: err, sentinel: ErrInvalidDeviceCode} + default: + return nil, &codeRejectedError{err: err, sentinel: ErrInvalidConfirmationCode} + } +} + +// codeRejectedError shows the server's error for a rejected code and matches +// sentinel with errors.Is. +type codeRejectedError struct { + err error + sentinel error +} + +func (e *codeRejectedError) Error() string { + return e.err.Error() +} + +func (e *codeRejectedError) Unwrap() []error { + return []error{e.err, e.sentinel} +} + +// postDeviceAuthorization makes the RFC 8628 device authorization request. // Unlike oauth2.Config.DeviceAuth, it sends the request with ctx, so that the // login timeout and cancellation stop a stalled request. -func (a *PersistentAuth) requestDeviceAuthorization(ctx context.Context, cfg *oauth2.Config) (*oauth2.DeviceAuthResponse, error) { +func (a *PersistentAuth) postDeviceAuthorization(ctx context.Context, cfg *oauth2.Config) (*oauth2.DeviceAuthResponse, error) { body, err := a.postForm(ctx, cfg.Endpoint.DeviceAuthURL, url.Values{ "client_id": {cfg.ClientID}, "scope": {strings.Join(cfg.Scopes, " ")}, @@ -235,10 +321,10 @@ func (a *PersistentAuth) postForm(ctx context.Context, endpoint string, form url return body, nil } -// normalizeCode lets users enter a code the way it is displayed. Databricks +// NormalizeCode lets users enter a code the way it is displayed. Databricks // codes consist of 8 uppercase alphanumeric characters and are compared // case-sensitively, but are often shown as two groups of four separated by a // dash (e.g. FTFA-QQKY). -func normalizeCode(code string) string { +func NormalizeCode(code string) string { return strings.NewReplacer("-", "", " ", "").Replace(strings.ToUpper(strings.TrimSpace(code))) } diff --git a/libs/auth/u2m/device_code_test.go b/libs/auth/u2m/device_code_test.go index f976ef5555d..59a15320c26 100644 --- a/libs/auth/u2m/device_code_test.go +++ b/libs/auth/u2m/device_code_test.go @@ -133,6 +133,7 @@ func TestDeviceCodeChallenge(t *testing.T) { assert.Equal(t, DeviceAuthorization{ UserCode: testUserCode, VerificationURI: "https://workspace.test/device/verification", + DeviceCode: testDeviceCode, }, got) assert.Equal(t, "access-token", tok.AccessToken) assert.Equal(t, "refresh-token", tok.RefreshToken) @@ -401,6 +402,88 @@ func TestDeviceCodeChallenge_AccountHost(t *testing.T) { assert.EqualError(t, err, "device code login is only supported for workspace hosts") } +func TestRequestDeviceAuthorization(t *testing.T) { + f := &fakeDeviceServer{expiresIn: 600} + p := newDeviceCodeAuth(t, f, true) + + da, err := p.RequestDeviceAuthorization(t.Context()) + require.NoError(t, err) + assert.Equal(t, &DeviceAuthorization{ + UserCode: testUserCode, + VerificationURI: "https://workspace.test/device/verification", + DeviceCode: testDeviceCode, + }, da) + assert.Equal(t, int32(0), f.tokenRequests.Load()) +} + +func TestRequestDeviceAuthorization_Errors(t *testing.T) { + _, err := newDeviceCodeAuth(t, &fakeDeviceServer{}, false).RequestDeviceAuthorization(t.Context()) + assert.ErrorIs(t, err, ErrDeviceCodeNotSupported) + + _, err = newDeviceCodeAuth(t, &fakeDeviceServer{ + deviceAuthStatus: http.StatusBadRequest, + deviceAuthBody: `{"error": "unauthorized_client", "error_description": "Client is not allowed to use device code"}`, + }, true).RequestDeviceAuthorization(t.Context()) + assert.EqualError(t, err, "device authorization: Client is not allowed to use device code (error code: unauthorized_client)") +} + +func TestExchangeDeviceCode(t *testing.T) { + f := &fakeDeviceServer{} + p := newDeviceCodeAuth(t, f, true) + + tok, err := p.ExchangeDeviceCode(t.Context(), testDeviceCode, " ftfa-qqky\n") + require.NoError(t, err) + assert.Equal(t, "access-token", tok.AccessToken) + assert.Equal(t, "refresh-token", tok.RefreshToken) + assert.Equal(t, int32(1), f.tokenRequests.Load()) +} + +func TestExchangeDeviceCode_Errors(t *testing.T) { + tests := []struct { + name string + server *fakeDeviceServer + wantErr string + wantInvalidConfirmationCode bool + wantInvalidDeviceCode bool + }{ + { + name: "wrong confirmation code", + server: &fakeDeviceServer{}, + wantErr: "token exchange: Invalid confirmation code (error code: invalid_request)", + wantInvalidConfirmationCode: true, + }, + { + 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)", + wantInvalidDeviceCode: true, + }, + { + 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) { + p := newDeviceCodeAuth(t, tc.server, true) + _, err := p.ExchangeDeviceCode(t.Context(), testDeviceCode, "AAAAAAAA") + assert.EqualError(t, err, tc.wantErr) + assert.Equal(t, tc.wantInvalidConfirmationCode, errors.Is(err, ErrInvalidConfirmationCode)) + assert.Equal(t, tc.wantInvalidDeviceCode, errors.Is(err, ErrInvalidDeviceCode)) + _, ok := errors.AsType[*httpclient.HttpError](err) + assert.True(t, ok, "want *httpclient.HttpError in %v", err) + assert.Equal(t, int32(1), tc.server.tokenRequests.Load()) + }) + } +} + func TestNormalizeCode(t *testing.T) { tests := []struct { in string @@ -412,6 +495,6 @@ func TestNormalizeCode(t *testing.T) { {" FTFA QQKY \n", "FTFAQQKY"}, } for _, tc := range tests { - assert.Equal(t, tc.want, normalizeCode(tc.in), tc.in) + assert.Equal(t, tc.want, NormalizeCode(tc.in), tc.in) } }