From eb65ef40b5c75fe3baf0c77ac2148d9bb509ba34 Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:16:40 +0000 Subject: [PATCH 1/2] [CP-768] fix(auth): send audience on device-code login Auth0 does not apply the resource parameter on /oauth/device/code, so that request must use audience. The poll stays a plain device-code grant, backs off on slow_down, and the loopback callback checks iss when Auth0 sends it. --- docs/reference/qcloud_auth_login.md | 7 +- internal/cmd/auth/login.go | 7 +- internal/oauth/device.go | 35 +++++++- internal/oauth/login.go | 60 ++++++++------ internal/oauth/login_test.go | 119 ++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 30 deletions(-) diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md index 58bc0e9..a62bf8b 100644 --- a/docs/reference/qcloud_auth_login.md +++ b/docs/reference/qcloud_auth_login.md @@ -7,7 +7,12 @@ Log in with a browser or device code Log in and store a refreshable access token for the current endpoint. The default flow is authorization code + PKCE with a loopback listener on -127.0.0.1 (RFC 8252). Use --device on SSH or when no browser is available. +127.0.0.1 (RFC 8252). The browser callback checks state and, when Auth0 sends +it, iss. + +Use --device on SSH or when no browser is available. The CLI only prints the +verification URL and polls. Sign-in and the Qdrant consent page happen in the +browser; a declined consent ends the poll with access denied. The login URL comes from the gateway's unauthenticated protected-resource metadata when OAuth is enabled, otherwise from rewriting grpc.cloud.qdrant.io diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 2fa9cc2..ab13b1d 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -16,7 +16,12 @@ func newLoginCommand(s *state.State) *cobra.Command { Long: `Log in and store a refreshable access token for the current endpoint. The default flow is authorization code + PKCE with a loopback listener on -127.0.0.1 (RFC 8252). Use --device on SSH or when no browser is available. +127.0.0.1 (RFC 8252). The browser callback checks state and, when Auth0 sends +it, iss. + +Use --device on SSH or when no browser is available. The CLI only prints the +verification URL and polls. Sign-in and the Qdrant consent page happen in the +browser; a declined consent ends the poll with access denied. The login URL comes from the gateway's unauthenticated protected-resource metadata when OAuth is enabled, otherwise from rewriting grpc.cloud.qdrant.io diff --git a/internal/oauth/device.go b/internal/oauth/device.go index 39fdba4..47ec83f 100644 --- a/internal/oauth/device.go +++ b/internal/oauth/device.go @@ -27,10 +27,13 @@ func (d Discoverer) deviceLogin(ctx context.Context, opts LoginOptions, disc Dis return Token{}, fmt.Errorf("authorization server does not advertise device_authorization_endpoint") } + // Auth0 accepts RFC 8707 resource on /authorize and refresh, not on + // /oauth/device/code. That endpoint takes audience. The value is the same + // API identifier discovery already resolved. form := url.Values{ "client_id": {opts.ClientID}, "scope": {scope}, - "resource": {disc.Resource}, + "audience": {disc.Resource}, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, as.DeviceAuthorizationEndpoint, strings.NewReader(form.Encode())) @@ -71,7 +74,11 @@ func (d Discoverer) deviceLogin(ctx context.Context, opts LoginOptions, disc Dis } if opts.Output != nil { - fmt.Fprintf(opts.Output, "Open %s and enter code %s\n", verify, dc.UserCode) + if dc.VerificationURIComplete != "" { + fmt.Fprintf(opts.Output, "Open %s\nConfirm the code %s in the browser. Login continues on the Qdrant consent page; this command waits until that finishes.\n", verify, dc.UserCode) + } else { + fmt.Fprintf(opts.Output, "Open %s and enter code %s\nLogin continues on the Qdrant consent page; this command waits until that finishes.\n", verify, dc.UserCode) + } } if opts.OpenURL != nil && verify != "" { @@ -114,7 +121,6 @@ func (d Discoverer) deviceLogin(ctx context.Context, opts LoginOptions, disc Dis "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, "device_code": {dc.DeviceCode}, "client_id": {opts.ClientID}, - "resource": {disc.Resource}, } tok, err := d.exchange(ctx, as.TokenEndpoint, tokForm, opts.Now(), base) @@ -122,9 +128,30 @@ func (d Discoverer) deviceLogin(ctx context.Context, opts LoginOptions, disc Dis return tok, nil } - if !strings.Contains(err.Error(), "authorization_pending") && !strings.Contains(err.Error(), "slow_down") { + next, again := nextDevicePollInterval(interval, err) + if !again { return Token{}, err } + + if next != interval { + interval = next + ticker.Reset(interval) + } + } +} + +// nextDevicePollInterval applies RFC 8628 polling. authorization_pending retries +// at the same interval. slow_down adds five seconds. Anything else, including +// access_denied from a declined consent and expired_token, stops the poll. +func nextDevicePollInterval(interval time.Duration, err error) (time.Duration, bool) { + msg := err.Error() + switch { + case strings.Contains(msg, "slow_down"): + return interval + 5*time.Second, true + case strings.Contains(msg, "authorization_pending"): + return interval, true + default: + return interval, false } } diff --git a/internal/oauth/login.go b/internal/oauth/login.go index 378db22..a2553ad 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -103,7 +103,7 @@ func (d Discoverer) codeLogin(ctx context.Context, opts LoginOptions, disc Disco defer ln.Close() redirectURL = "http://" + ln.Addr().String() + "/callback" - wait = loopbackWaiter(ln, opts.Output) + wait = loopbackWaiter(ln, opts.Output, disc.Issuer) } else if redirectURL == "" { redirectURL = "http://127.0.0.1:1/callback" } @@ -178,35 +178,17 @@ func listenLoopback(listen func() (net.Listener, error)) (net.Listener, error) { return ln, nil } -func loopbackWaiter(ln net.Listener, out io.Writer) WaitCodeFunc { +func loopbackWaiter(ln net.Listener, out io.Writer, issuer string) WaitCodeFunc { return func(ctx context.Context, _, wantState string) (string, error) { codeCh := make(chan string, 1) errCh := make(chan error, 1) mux := http.NewServeMux() mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if errParam := q.Get("error"); errParam != "" { - msg := q.Get("error_description") - if msg == "" { - msg = errParam - } - - http.Error(w, msg, http.StatusBadRequest) - errCh <- fmt.Errorf("authorization: %s", msg) - return - } - - if q.Get("state") != wantState { - http.Error(w, "state mismatch", http.StatusBadRequest) - errCh <- fmt.Errorf("authorization state mismatch") - return - } - - code := q.Get("code") - if code == "" { - http.Error(w, "missing code", http.StatusBadRequest) - errCh <- fmt.Errorf("authorization response missing code") + code, err := authorizationCallback(r.URL.Query(), wantState, issuer) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + errCh <- err return } @@ -240,3 +222,33 @@ func loopbackWaiter(ln net.Listener, out io.Writer) WaitCodeFunc { } } } + +// authorizationCallback checks the loopback redirect. state is the client's +// CSRF value from /authorize, not Auth0's consent-page redirect state. +// iss is checked when Auth0 sends it (RFC 9207). A missing iss is allowed so +// login still works before that tenant toggle is on. +func authorizationCallback(q url.Values, wantState, wantIssuer string) (string, error) { + if errParam := q.Get("error"); errParam != "" { + msg := q.Get("error_description") + if msg == "" { + msg = errParam + } + + return "", fmt.Errorf("authorization: %s", msg) + } + + if q.Get("state") != wantState { + return "", fmt.Errorf("authorization state mismatch") + } + + if iss := q.Get("iss"); iss != "" && canonicalizeIssuer(iss) != canonicalizeIssuer(wantIssuer) { + return "", fmt.Errorf("authorization issuer mismatch") + } + + code := q.Get("code") + if code == "" { + return "", fmt.Errorf("authorization response missing code") + } + + return code, nil +} diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index 1cc3eac..00b2849 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -6,7 +6,9 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -80,3 +82,120 @@ func TestLogin_RequiresClientID(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "client id") } + +func TestAuthorizationCallback(t *testing.T) { + t.Parallel() + + code, err := authorizationCallback(url.Values{ + "code": {"abc"}, + "state": {"s"}, + "iss": {"https://login.cloud.qdrant.io/"}, + }, "s", "https://login.cloud.qdrant.io/") + require.NoError(t, err) + assert.Equal(t, "abc", code) + + _, err = authorizationCallback(url.Values{"code": {"abc"}, "state": {"other"}}, "s", "https://login.cloud.qdrant.io/") + require.Error(t, err) + assert.Contains(t, err.Error(), "state mismatch") + + _, err = authorizationCallback(url.Values{ + "code": {"abc"}, + "state": {"s"}, + "iss": {"https://evil.example/"}, + }, "s", "https://login.cloud.qdrant.io/") + require.Error(t, err) + assert.Contains(t, err.Error(), "issuer mismatch") + + _, err = authorizationCallback(url.Values{"error": {"access_denied"}}, "s", "https://login.cloud.qdrant.io/") + require.Error(t, err) + assert.Contains(t, err.Error(), "access_denied") +} + +func TestNextDevicePollInterval(t *testing.T) { + t.Parallel() + + next, again := nextDevicePollInterval(5*time.Second, assert.AnError) + assert.False(t, again) + assert.Equal(t, 5*time.Second, next) + + next, again = nextDevicePollInterval(5*time.Second, errString("token endpoint: authorization_pending")) + assert.True(t, again) + assert.Equal(t, 5*time.Second, next) + + next, again = nextDevicePollInterval(5*time.Second, errString("token endpoint: slow_down")) + assert.True(t, again) + assert.Equal(t, 10*time.Second, next) +} + +type errString string + +func (e errString) Error() string { return string(e) } + +func TestLogin_DeviceUsesAudience(t *testing.T) { + t.Parallel() + + var deviceForm, tokenForm url.Values + polls := 0 + + mux := http.NewServeMux() + mux.HandleFunc(wellKnownPRM, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "resource": "https://api.development-cloud.qdrant.io", + "authorization_servers": []string{"https://login.development-cloud.qdrant.io/"}, + }) + }) + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": "https://login.development-cloud.qdrant.io/", + "authorization_endpoint": "https://login.development-cloud.qdrant.io/authorize", + "token_endpoint": "https://login.development-cloud.qdrant.io/oauth/token", + "device_authorization_endpoint": "https://login.development-cloud.qdrant.io/oauth/device/code", + }) + }) + mux.HandleFunc("/oauth/device/code", func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + deviceForm = r.Form + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "dev-1", + "user_code": "ABCD-EFGH", + "verification_uri_complete": "https://login.development-cloud.qdrant.io/activate?user_code=ABCD-EFGH", + "expires_in": 600, + "interval": 1, + }) + }) + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + tokenForm = r.Form + polls++ + if polls == 1 { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending", "error_description": "authorization_pending"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at-1", + "refresh_token": "rt-1", + "token_type": "Bearer", + "expires_in": 3600, + }) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + d := Discoverer{HTTPClient: rewriteClient(t, srv.URL)} + tok, _, err := d.Login(context.Background(), LoginOptions{ + Endpoint: "grpc.development-cloud.qdrant.io:443", + ClientID: "cli-client", + Device: true, + Now: time.Now, + Output: io.Discard, + }) + require.NoError(t, err) + assert.Equal(t, "at-1", tok.AccessToken) + assert.Equal(t, "https://api.development-cloud.qdrant.io", deviceForm.Get("audience")) + assert.Empty(t, deviceForm.Get("resource")) + assert.Equal(t, "dev-1", tokenForm.Get("device_code")) + assert.Empty(t, tokenForm.Get("resource")) + assert.Empty(t, tokenForm.Get("audience")) +} From a01abf7aa92efd2f14348f2d50f4dddd0f5b316a Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Mon, 19 Oct 2026 08:14:31 +0200 Subject: [PATCH 2/2] test(auth): keep require out of http handlers in device login test golangci-lint (testifylint go-require, wsl_v5) flagged the device-login test. A form parse error now returns 400 from the handler, which still fails the login. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/oauth/login_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index 00b2849..59294e2 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -153,7 +153,12 @@ func TestLogin_DeviceUsesAudience(t *testing.T) { }) }) mux.HandleFunc("/oauth/device/code", func(w http.ResponseWriter, r *http.Request) { - require.NoError(t, r.ParseForm()) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + deviceForm = r.Form _ = json.NewEncoder(w).Encode(map[string]any{ "device_code": "dev-1", @@ -164,7 +169,12 @@ func TestLogin_DeviceUsesAudience(t *testing.T) { }) }) mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { - require.NoError(t, r.ParseForm()) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + tokenForm = r.Form polls++ if polls == 1 { @@ -172,6 +182,7 @@ func TestLogin_DeviceUsesAudience(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending", "error_description": "authorization_pending"}) return } + _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "at-1", "refresh_token": "rt-1",