From 290e2617b5f3826f5bb404c950a3fee24ec718fb Mon Sep 17 00:00:00 2001 From: Jyotirmoy Bandyopadhayaya Date: Sun, 25 Oct 2026 06:44:47 +0530 Subject: [PATCH] cli/login: device-code login for registries that advertise it Docker Hub already signs in through a browser. A self-hosted registry can now offer the same flow by advertising a device authorization endpoint on the GET /v2/ bearer challenge. Password login stays the fallback, and the Hub login path is unchanged. Closes #7435 Signed-off-by: Jyotirmoy Bandyopadhayaya --- cli/command/registry/device_login.go | 139 +++++++++++++ cli/command/registry/login.go | 19 +- docs/reference/commandline/login.md | 8 + internal/registry/device.go | 280 +++++++++++++++++++++++++++ internal/registry/device_test.go | 129 ++++++++++++ 5 files changed, 568 insertions(+), 7 deletions(-) create mode 100644 cli/command/registry/device_login.go create mode 100644 internal/registry/device.go create mode 100644 internal/registry/device_test.go diff --git a/cli/command/registry/device_login.go b/cli/command/registry/device_login.go new file mode 100644 index 000000000000..64d5ea8590bd --- /dev/null +++ b/cli/command/registry/device_login.go @@ -0,0 +1,139 @@ +package registry + +import ( + "bufio" + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/internal/registry" + "github.com/docker/cli/internal/tui" + registrytypes "github.com/moby/moby/api/types/registry" + "github.com/moby/moby/client" + "github.com/morikuni/aec" + "github.com/pkg/browser" + "github.com/sirupsen/logrus" +) + +// tryRegistryDeviceLogin attempts a browser login when the registry advertises one. +// The boolean is false when the caller should fall back to a password prompt. +func tryRegistryDeviceLogin(ctx context.Context, dockerCLI command.Cli, serverAddress string) (string, error, bool) { + svc, err := registry.NewService(registry.ServiceOptions{}) + if err != nil { + logrus.WithError(err).Debug("device login unavailable") + return "", nil, false + } + endpoints, err := svc.Endpoints(ctx, serverAddress) + if err != nil || len(endpoints) == 0 { + return "", nil, false + } + + var offer registry.DeviceLogin + var found bool + for _, endpoint := range endpoints { + offer, err = registry.DiscoverDeviceLogin(ctx, endpoint, command.UserAgent()) + if err == nil { + found = true + break + } + if errors.Is(err, registry.ErrDeviceLoginStartFail) { + logrus.WithError(err).Debug("device login challenge was not usable") + _, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n") + return "", nil, false + } + logrus.WithError(err).Debug("registry did not offer device login") + } + if !found { + return "", nil, false + } + + httpClient := &http.Client{Timeout: 15 * time.Second} + code, err := registry.RequestDeviceCode(ctx, httpClient, offer.DeviceEndpoint) + if err != nil { + logrus.WithError(err).Debug("failed to start registry device login") + _, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n") + return "", nil, false + } + + cred, err := waitForDeviceCredential(ctx, dockerCLI, httpClient, offer.TokenEndpoint, code) + if err != nil { + return "", err, true + } + + response, err := loginWithRegistry(ctx, dockerCLI.Client(), client.RegistryLoginOptions{ + Username: cred.Username, + Password: cred.Secret, + ServerAddress: serverAddress, + IdentityToken: cred.IdentityToken, + }) + if err != nil { + return "", err, true + } + stored := registrytypes.AuthConfig{ + Username: cred.Username, + Password: cred.Secret, + ServerAddress: serverAddress, + IdentityToken: cred.IdentityToken, + } + if response.Auth.IdentityToken != "" { + stored.Password = "" + stored.IdentityToken = response.Auth.IdentityToken + } + if err := storeCredentials(dockerCLI.ConfigFile(), stored); err != nil { + return "", err, true + } + return response.Auth.Status, nil, true +} + +func waitForDeviceCredential(ctx context.Context, dockerCLI command.Cli, httpClient *http.Client, tokenEndpoint string, code registry.DeviceCode) (registry.DeviceCredential, error) { + ctx, cancel := context.WithTimeout(ctx, code.ExpiresIn) + defer cancel() + + out := tui.NewOutput(dockerCLI.Err()) + out.PrintNote("Using web-based login. To sign in with credentials on the command line, use 'docker login -u '\n") + _, _ = fmt.Fprintln(dockerCLI.Err(), "Your one-time device confirmation code is:", aec.Bold.Apply(code.UserCode)) + _, _ = fmt.Fprintln(dockerCLI.Err(), "Open this URL to authenticate:", aec.Underline.Apply(code.VerificationURI)) + _, _ = fmt.Fprintln(dockerCLI.Err(), "\n"+aec.Bold.Apply("Press ENTER")+" to open the URL in your browser, or "+aec.Bold.Apply("Ctrl+C")+" to cancel.") + + openURL := code.VerificationURI + if code.VerificationURIComplete != "" { + openURL = code.VerificationURIComplete + } + + credCh := make(chan registry.DeviceCredential, 1) + errCh := make(chan error, 1) + go func() { + cred, err := registry.PollDeviceToken(ctx, httpClient, tokenEndpoint, code.DeviceCode, code.Interval, nil) + if err != nil { + errCh <- err + return + } + credCh <- cred + }() + go func() { + reader := bufio.NewReader(dockerCLI.In()) + if _, err := reader.ReadString('\n'); err != nil || ctx.Err() != nil { + return + } + if err := browser.OpenURL(openURL); err != nil { + _, _ = fmt.Fprintln(dockerCLI.Err(), "Open the URL above manually to continue.") + return + } + _, _ = fmt.Fprintln(dockerCLI.Err(), "\nOpening browser…") + }() + + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return registry.DeviceCredential{}, errors.New("timed out waiting for the browser login") + } + return registry.DeviceCredential{}, errors.New("login canceled") + case err := <-errCh: + return registry.DeviceCredential{}, fmt.Errorf("failed waiting for authentication: %w", err) + case cred := <-credCh: + return cred, nil + } +} diff --git a/cli/command/registry/login.go b/cli/command/registry/login.go index 2b522910d800..f25f1e20e368 100644 --- a/cli/command/registry/login.go +++ b/cli/command/registry/login.go @@ -239,15 +239,20 @@ func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, de return "", errors.New("error: cannot perform an interactive login from a non-TTY device") } - // If we're logging into the index server and the user didn't provide a username or password, use the device flow - if serverAddress == registry.IndexServer && opts.user == "" && opts.password == "" { - msg, err := loginWithDeviceCodeFlow(ctx, dockerCLI) - if err == nil || !errors.Is(err, manager.ErrDeviceLoginStartFail) { + // With no username or password, Docker Hub uses its own device-code login. + // Any other registry can advertise the same kind of browser login on GET /v2/. + if opts.user == "" && opts.password == "" { + if serverAddress == registry.IndexServer { + msg, err := loginWithDeviceCodeFlow(ctx, dockerCLI) + if err == nil || !errors.Is(err, manager.ErrDeviceLoginStartFail) { + return msg, err + } + + logrus.WithError(err).Debug("failed to start web-based login") + _, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n") + } else if msg, err, handled := tryRegistryDeviceLogin(ctx, dockerCLI, serverAddress); handled { return msg, err } - - logrus.WithError(err).Debug("failed to start web-based login") - _, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n") } return loginWithUsernameAndPassword(ctx, dockerCLI, opts, defaultUsername, serverAddress) diff --git a/docs/reference/commandline/login.md b/docs/reference/commandline/login.md index 458edd7b91b0..cfb429966f8f 100644 --- a/docs/reference/commandline/login.md +++ b/docs/reference/commandline/login.md @@ -202,6 +202,14 @@ adding the server name. $ docker login registry.example.com ``` +If that registry answers `GET /v2/` with a bearer challenge that includes +`device_authorization_endpoint`, and you did not pass a username or password, +`docker login` uses the same browser device-code flow as Docker Hub. The +registry's token response must be a username plus a secret, or an identity +token. Those credentials are stored in the usual credential store. A registry +that does not advertise the endpoint still asks for a username and password. +Passing `-u` or `--password-stdin` always uses the password login. + By default, the `docker login` command assumes that the registry listens on port 443 or 80. If the registry listens on a different port, you can specify it by adding the port number to the server name. diff --git a/internal/registry/device.go b/internal/registry/device.go new file mode 100644 index 000000000000..183037d77b38 --- /dev/null +++ b/internal/registry/device.go @@ -0,0 +1,280 @@ +package registry + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/docker/distribution/registry/client/auth/challenge" +) + +const ( + deviceAuthParam = "device_authorization_endpoint" + tokenEndpointParam = "token_endpoint" + deviceClientID = AuthClientID + + // deviceGrantType is the RFC 8628 device-code grant. + deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" +) + +// ErrNoDeviceLogin means the registry did not advertise a device-code login. +var ErrNoDeviceLogin = errors.New("registry did not advertise device login") + +// ErrDeviceLoginStartFail means the device-code flow could not be started. +// Callers should fall back to a username and password prompt. +var ErrDeviceLoginStartFail = errors.New("failed to start device code flow login") + +// DeviceLogin is a registry that offered a browser login on its /v2/ challenge. +type DeviceLogin struct { + // DeviceEndpoint is where the client starts the device-code flow. + DeviceEndpoint string + // TokenEndpoint is where the client polls until the browser login finishes. + TokenEndpoint string +} + +// DeviceCode is the RFC 8628 device authorization response. +type DeviceCode struct { + DeviceCode string + UserCode string + VerificationURI string + VerificationURIComplete string + ExpiresIn time.Duration + Interval time.Duration +} + +// DeviceCredential is the registry credential returned when the poll succeeds. +type DeviceCredential struct { + Username string + Secret string + IdentityToken string +} + +// DiscoverDeviceLogin reads a device-code login offer from GET /v2/. +// ErrNoDeviceLogin is returned when the registry answers but does not advertise one. +func DiscoverDeviceLogin(ctx context.Context, endpoint APIEndpoint, userAgent string) (DeviceLogin, error) { + ping, err := url.Parse(strings.TrimRight(endpoint.URL.String(), "/") + "/v2/") + if err != nil { + return DeviceLogin{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ping.String(), http.NoBody) + if err != nil { + return DeviceLogin{}, err + } + if userAgent != "" { + req.Header.Set("User-Agent", userAgent) + } + client := &http.Client{Transport: newTransport(endpoint.TLSConfig), Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return DeviceLogin{}, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + + if resp.StatusCode != http.StatusUnauthorized { + return DeviceLogin{}, ErrNoDeviceLogin + } + for _, item := range challenge.ResponseChallenges(resp) { + if item.Scheme != "bearer" { + continue + } + deviceEndpoint := strings.TrimSpace(item.Parameters[deviceAuthParam]) + if deviceEndpoint == "" { + continue + } + tokenEndpoint := strings.TrimSpace(item.Parameters[tokenEndpointParam]) + if tokenEndpoint == "" { + tokenEndpoint = strings.TrimSpace(item.Parameters["realm"]) + } + login := DeviceLogin{DeviceEndpoint: deviceEndpoint, TokenEndpoint: tokenEndpoint} + if err := login.validate(endpoint.URL.Scheme); err != nil { + return DeviceLogin{}, fmt.Errorf("%w: %w", ErrDeviceLoginStartFail, err) + } + return login, nil + } + return DeviceLogin{}, ErrNoDeviceLogin +} + +func (d DeviceLogin) validate(registryScheme string) error { + deviceURL, err := parseHTTPURL(d.DeviceEndpoint) + if err != nil { + return fmt.Errorf("device authorization endpoint: %w", err) + } + tokenURL, err := parseHTTPURL(d.TokenEndpoint) + if err != nil { + return fmt.Errorf("token endpoint: %w", err) + } + if registryScheme == "https" && (deviceURL.Scheme != "https" || tokenURL.Scheme != "https") { + return errors.New("device login endpoints must use https") + } + return nil +} + +func parseHTTPURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + if u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return nil, errors.New("must be an absolute http(s) URL") + } + return u, nil +} + +// RequestDeviceCode starts an RFC 8628 device authorization against endpoint. +func RequestDeviceCode(ctx context.Context, client *http.Client, endpoint string) (DeviceCode, error) { + form := url.Values{} + form.Set("client_id", deviceClientID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return DeviceCode{}, fmt.Errorf("%w: %w", ErrDeviceLoginStartFail, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + return DeviceCode{}, fmt.Errorf("%w: %w", ErrDeviceLoginStartFail, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return DeviceCode{}, fmt.Errorf("%w: %w", ErrDeviceLoginStartFail, err) + } + if resp.StatusCode != http.StatusOK { + return DeviceCode{}, fmt.Errorf("%w: device authorization returned %s", ErrDeviceLoginStartFail, resp.Status) + } + var raw struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return DeviceCode{}, fmt.Errorf("%w: %w", ErrDeviceLoginStartFail, err) + } + if raw.Error != "" { + return DeviceCode{}, fmt.Errorf("%w: %s", ErrDeviceLoginStartFail, deviceErrorText(raw.Error, raw.ErrorDescription)) + } + if raw.DeviceCode == "" || raw.UserCode == "" || raw.VerificationURI == "" { + return DeviceCode{}, fmt.Errorf("%w: device authorization response is incomplete", ErrDeviceLoginStartFail) + } + interval := time.Duration(raw.Interval) * time.Second + if interval < 5*time.Second { + interval = 5 * time.Second + } + expires := time.Duration(raw.ExpiresIn) * time.Second + if expires <= 0 { + expires = 5 * time.Minute + } + return DeviceCode{ + DeviceCode: raw.DeviceCode, + UserCode: raw.UserCode, + VerificationURI: raw.VerificationURI, + VerificationURIComplete: raw.VerificationURIComplete, + ExpiresIn: expires, + Interval: interval, + }, nil +} + +// PollDeviceToken polls the token endpoint until the browser login finishes, +// is refused, or ctx ends. sleep is used between polls so tests need not wait. +func PollDeviceToken(ctx context.Context, client *http.Client, tokenEndpoint, deviceCode string, interval time.Duration, sleep func(context.Context, time.Duration) error) (DeviceCredential, error) { + if sleep == nil { + sleep = sleepContext + } + if interval < 5*time.Second { + interval = 5 * time.Second + } + for { + if err := ctx.Err(); err != nil { + return DeviceCredential{}, err + } + cred, pending, next, err := pollDeviceOnce(ctx, client, tokenEndpoint, deviceCode) + if err != nil { + return DeviceCredential{}, err + } + if !pending { + return cred, nil + } + if next > 0 { + interval += next + } + if err := sleep(ctx, interval); err != nil { + return DeviceCredential{}, err + } + } +} + +func pollDeviceOnce(ctx context.Context, client *http.Client, tokenEndpoint, deviceCode string) (DeviceCredential, bool, time.Duration, error) { + form := url.Values{} + form.Set("grant_type", deviceGrantType) + form.Set("device_code", deviceCode) + form.Set("client_id", deviceClientID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return DeviceCredential{}, false, 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + return DeviceCredential{}, false, 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return DeviceCredential{}, false, 0, err + } + var raw struct { + Username string `json:"username"` + Secret string `json:"secret"` + IdentityToken string `json:"identity_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return DeviceCredential{}, false, 0, fmt.Errorf("device token response: %w", err) + } + switch raw.Error { + case "": + if resp.StatusCode != http.StatusOK { + return DeviceCredential{}, false, 0, fmt.Errorf("device token endpoint returned %s", resp.Status) + } + if raw.Username == "" || (raw.Secret == "" && raw.IdentityToken == "") { + return DeviceCredential{}, false, 0, errors.New("device token response did not include a registry credential") + } + return DeviceCredential{Username: raw.Username, Secret: raw.Secret, IdentityToken: raw.IdentityToken}, false, 0, nil + case "authorization_pending": + return DeviceCredential{}, true, 0, nil + case "slow_down": + return DeviceCredential{}, true, 5 * time.Second, nil + default: + return DeviceCredential{}, false, 0, errors.New(deviceErrorText(raw.Error, raw.ErrorDescription)) + } +} + +func deviceErrorText(code, description string) string { + if description != "" { + return description + } + return code +} + +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/registry/device_test.go b/internal/registry/device_test.go new file mode 100644 index 000000000000..34bc7c53ab75 --- /dev/null +++ b/internal/registry/device_test.go @@ -0,0 +1,129 @@ +package registry + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestDiscoverDeviceLogin(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/" { + http.NotFound(w, r) + return + } + w.Header().Set("WWW-Authenticate", `Bearer realm="http://token.example/token",service="registry",device_authorization_endpoint="http://token.example/device"`) + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + assert.NilError(t, err) + + login, err := DiscoverDeviceLogin(context.Background(), APIEndpoint{URL: u}, "test") + assert.NilError(t, err) + assert.Check(t, is.Equal("http://token.example/device", login.DeviceEndpoint)) + assert.Check(t, is.Equal("http://token.example/token", login.TokenEndpoint)) +} + +func TestDiscoverDeviceLoginUsesTokenEndpointParam(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("WWW-Authenticate", `Bearer realm="registry",device_authorization_endpoint="http://token.example/device",token_endpoint="http://token.example/oauth/token"`) + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + assert.NilError(t, err) + + login, err := DiscoverDeviceLogin(context.Background(), APIEndpoint{URL: u}, "") + assert.NilError(t, err) + assert.Check(t, is.Equal("http://token.example/oauth/token", login.TokenEndpoint)) +} + +func TestDiscoverDeviceLoginAbsent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("WWW-Authenticate", `Basic realm="registry"`) + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + assert.NilError(t, err) + + _, err = DiscoverDeviceLogin(context.Background(), APIEndpoint{URL: u}, "") + assert.Check(t, is.ErrorIs(err, ErrNoDeviceLogin)) +} + +func TestDiscoverDeviceLoginRejectsHTTPEndpointsOnHTTPS(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("WWW-Authenticate", `Bearer realm="http://token.example/token",device_authorization_endpoint="http://token.example/device"`) + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + assert.NilError(t, err) + + _, err = DiscoverDeviceLogin(context.Background(), APIEndpoint{URL: u, TLSConfig: srv.Client().Transport.(*http.Transport).TLSClientConfig}, "") + assert.Check(t, is.ErrorIs(err, ErrDeviceLoginStartFail)) +} + +func TestDeviceCodeExchange(t *testing.T) { + var polls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil || r.PostForm.Get("client_id") != AuthClientID { + http.Error(w, "client", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"device_code":"dev","user_code":"ABCD-EFGH","verification_uri":"http://example.test/activate","verification_uri_complete":"http://example.test/activate?user_code=ABCD-EFGH","expires_in":60,"interval":5}`)) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + n := polls.Add(1) + if n == 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + _, _ = w.Write([]byte(`{"username":"alice","secret":"s3cret"}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + code, err := RequestDeviceCode(context.Background(), srv.Client(), srv.URL+"/device") + assert.NilError(t, err) + assert.Check(t, is.Equal("ABCD-EFGH", code.UserCode)) + assert.Check(t, is.Equal(5*time.Second, code.Interval)) + + cred, err := PollDeviceToken(context.Background(), srv.Client(), srv.URL+"/token", code.DeviceCode, code.Interval, func(context.Context, time.Duration) error { + return nil + }) + assert.NilError(t, err) + assert.Check(t, is.Equal("alice", cred.Username)) + assert.Check(t, is.Equal("s3cret", cred.Secret)) + assert.Check(t, is.Equal(int32(2), polls.Load())) +} + +func TestPollDeviceTokenDenied(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"access_denied","error_description":"the sign-in was refused"}`)) + })) + t.Cleanup(srv.Close) + + _, err := PollDeviceToken(context.Background(), srv.Client(), srv.URL, "dev", 5*time.Second, func(context.Context, time.Duration) error { + return nil + }) + assert.ErrorContains(t, err, "the sign-in was refused") +}