Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/reference/qcloud_auth_login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion internal/cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 31 additions & 4 deletions internal/oauth/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -114,17 +121,37 @@ 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)
if err == nil {
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
}
}

Expand Down
60 changes: 36 additions & 24 deletions internal/oauth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
130 changes: 130 additions & 0 deletions internal/oauth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -80,3 +82,131 @@ 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) {
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",
"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) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)

return
}

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"))
}
Loading