From 776b4d7a6f04b0ad9662857d56e6aa1053c3a2f0 Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:34:28 +0000 Subject: [PATCH 01/25] feat(auth): add login with endpoint-inferred Auth0 issuer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover the login URL from QDRANT_CLOUD_ENDPOINT (grpc. → login.), preferring unauthenticated API gateway protected-resource metadata when OAuth is enabled. --- docs/reference/qcloud.md | 2 + docs/reference/qcloud_auth.md | 63 ++++++ docs/reference/qcloud_auth_discover.md | 50 +++++ docs/reference/qcloud_auth_login.md | 64 +++++++ docs/reference/qcloud_auth_logout.md | 45 +++++ docs/reference/qcloud_auth_status.md | 45 +++++ docs/reference/qcloud_auth_token.md | 46 +++++ internal/cli/root.go | 3 + internal/cmd/auth/auth.go | 47 +++++ internal/cmd/auth/auth_test.go | 179 ++++++++++++++++++ internal/cmd/auth/discover.go | 51 +++++ internal/cmd/auth/login.go | 81 ++++++++ internal/cmd/auth/logout.go | 50 +++++ internal/cmd/auth/status.go | 64 +++++++ internal/cmd/auth/token.go | 63 ++++++ internal/oauth/device.go | 171 +++++++++++++++++ internal/oauth/discover.go | 131 +++++++++++++ internal/oauth/discover_test.go | 53 ++++++ internal/oauth/doc.go | 9 + internal/oauth/endpoint.go | 93 +++++++++ internal/oauth/endpoint_test.go | 53 ++++++ internal/oauth/httptest_helpers_test.go | 31 +++ internal/oauth/login.go | 242 ++++++++++++++++++++++++ internal/oauth/login_test.go | 82 ++++++++ internal/oauth/oidc.go | 57 ++++++ internal/oauth/pkce.go | 22 +++ internal/oauth/store.go | 142 ++++++++++++++ internal/oauth/store_test.go | 45 +++++ internal/oauth/token.go | 109 +++++++++++ internal/qcloudapi/client.go | 26 ++- internal/qcloudapi/client_test.go | 14 +- internal/state/state.go | 87 ++++++++- 32 files changed, 2212 insertions(+), 8 deletions(-) create mode 100644 docs/reference/qcloud_auth.md create mode 100644 docs/reference/qcloud_auth_discover.md create mode 100644 docs/reference/qcloud_auth_login.md create mode 100644 docs/reference/qcloud_auth_logout.md create mode 100644 docs/reference/qcloud_auth_status.md create mode 100644 docs/reference/qcloud_auth_token.md create mode 100644 internal/cmd/auth/auth.go create mode 100644 internal/cmd/auth/auth_test.go create mode 100644 internal/cmd/auth/discover.go create mode 100644 internal/cmd/auth/login.go create mode 100644 internal/cmd/auth/logout.go create mode 100644 internal/cmd/auth/status.go create mode 100644 internal/cmd/auth/token.go create mode 100644 internal/oauth/device.go create mode 100644 internal/oauth/discover.go create mode 100644 internal/oauth/discover_test.go create mode 100644 internal/oauth/doc.go create mode 100644 internal/oauth/endpoint.go create mode 100644 internal/oauth/endpoint_test.go create mode 100644 internal/oauth/httptest_helpers_test.go create mode 100644 internal/oauth/login.go create mode 100644 internal/oauth/login_test.go create mode 100644 internal/oauth/oidc.go create mode 100644 internal/oauth/pkce.go create mode 100644 internal/oauth/store.go create mode 100644 internal/oauth/store_test.go create mode 100644 internal/oauth/token.go diff --git a/docs/reference/qcloud.md b/docs/reference/qcloud.md index cf48d40..13e26e9 100644 --- a/docs/reference/qcloud.md +++ b/docs/reference/qcloud.md @@ -7,6 +7,7 @@ Qdrant Cloud CLI The command-line interface for Qdrant Cloud. Get started: + qcloud auth login qcloud context set default --api-key --account-id qcloud cluster list @@ -29,6 +30,7 @@ Documentation: https://github.com/qdrant/qcloud-cli ### SEE ALSO * [qcloud account](qcloud_account.md) - Manage Qdrant Cloud accounts +* [qcloud auth](qcloud_auth.md) - Log in to Qdrant Cloud with a browser * [qcloud backup](qcloud_backup.md) - Manage Qdrant Cloud backups * [qcloud cloud-provider](qcloud_cloud-provider.md) - Manage cloud providers * [qcloud cloud-region](qcloud_cloud-region.md) - Manage cloud regions diff --git a/docs/reference/qcloud_auth.md b/docs/reference/qcloud_auth.md new file mode 100644 index 0000000..6487a50 --- /dev/null +++ b/docs/reference/qcloud_auth.md @@ -0,0 +1,63 @@ +## qcloud auth + +Log in to Qdrant Cloud with a browser + +### Synopsis + +Log in to Qdrant Cloud with a browser (or a device code) instead of pasting an API key. + +The Auth0 login host is not a separate flag. It is derived from the gRPC +endpoint (flag --endpoint, env QDRANT_CLOUD_ENDPOINT, or the active context): + + grpc.cloud.qdrant.io:443 → login.cloud.qdrant.io + grpc.staging-cloud.qdrant.io:443 → login.staging-cloud.qdrant.io + grpc.development-cloud.qdrant.io:443 → login.development-cloud.qdrant.io + +When OAuth is enabled, the gateway also advertises the issuer at the +unauthenticated URL https://api..qdrant.io/.well-known/oauth-protected-resource. +That document wins over hostname inference. + +After login, API calls send Authorization: Bearer . Management API keys +keep working unchanged when --api-key / QDRANT_CLOUD_API_KEY is set. + +### Examples + +``` +# Discover the login URL for the current endpoint (no Auth0 app required) +qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443 + +# Browser login (authorization code + PKCE on 127.0.0.1) +qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 --client-id + +# Device code (SSH / no browser) +qcloud auth login --device --scope read-only +``` + +### Options + +``` + -h, --help help for auth +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud](qcloud.md) - Qdrant Cloud CLI +* [qcloud auth discover](qcloud_auth_discover.md) - Show the OAuth login URL inferred from the API endpoint +* [qcloud auth login](qcloud_auth_login.md) - Log in with a browser or device code +* [qcloud auth logout](qcloud_auth_logout.md) - Log out and drop stored OAuth tokens +* [qcloud auth status](qcloud_auth_status.md) - Show OAuth login status for the current endpoint +* [qcloud auth token](qcloud_auth_token.md) - Print a fresh OAuth access token (sensitive) + diff --git a/docs/reference/qcloud_auth_discover.md b/docs/reference/qcloud_auth_discover.md new file mode 100644 index 0000000..b18de29 --- /dev/null +++ b/docs/reference/qcloud_auth_discover.md @@ -0,0 +1,50 @@ +## qcloud auth discover + +Show the OAuth login URL inferred from the API endpoint + +### Synopsis + +Print the OAuth API resource and authorization-server issuer for the current endpoint. + +The gRPC endpoint is turned into https://api..qdrant.io. The CLI then +GETs /.well-known/oauth-protected-resource with no credentials. If the gateway +returns authorization_servers, that issuer is used; otherwise login. +is inferred. No Auth0 client id is required. + +``` +qcloud auth discover [flags] +``` + +### Examples + +``` +# Production (default endpoint) +qcloud auth discover + +# Development +qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443 +``` + +### Options + +``` + -h, --help help for discover +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud auth](qcloud_auth.md) - Log in to Qdrant Cloud with a browser + diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md new file mode 100644 index 0000000..88e262a --- /dev/null +++ b/docs/reference/qcloud_auth_login.md @@ -0,0 +1,64 @@ +## qcloud auth login + +Log in with a browser or device code + +### Synopsis + +Log in through Auth0 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. + +The login URL comes from the gateway's unauthenticated protected-resource +metadata when OAuth is enabled, otherwise from rewriting grpc. to +login.. Override with --login-url only for debugging. + +The Auth0 native application ("Qdrant Cloud CLI") is not hard-coded yet. +Pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID after that app exists on +the tenant. Tokens are stored in credentials.yaml next to the config file +(mode 0600). + +``` +qcloud auth login [flags] +``` + +### Examples + +``` +# Browser login against development +qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 --client-id + +# Read-only session +qcloud auth login --scope read-only --client-id + +# Device code +qcloud auth login --device --client-id +``` + +### Options + +``` + --client-id string Auth0 native app client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID) + --device Use the device authorization grant instead of a loopback browser flow + -h, --help help for login + --login-url string Override the Auth0 issuer URL (defaults to discovery / endpoint inference) + --scope string OAuth scope: read-only or manage (default "manage") +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud auth](qcloud_auth.md) - Log in to Qdrant Cloud with a browser + diff --git a/docs/reference/qcloud_auth_logout.md b/docs/reference/qcloud_auth_logout.md new file mode 100644 index 0000000..c934c7a --- /dev/null +++ b/docs/reference/qcloud_auth_logout.md @@ -0,0 +1,45 @@ +## qcloud auth logout + +Log out and drop stored OAuth tokens + +### Synopsis + +Remove the stored OAuth token for the current endpoint. + +If the authorization server advertises a revocation endpoint, the refresh +token is revoked first. Management API keys in the config file are not +changed. + +``` +qcloud auth logout [flags] +``` + +### Examples + +``` +qcloud auth logout +``` + +### Options + +``` + -h, --help help for logout +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud auth](qcloud_auth.md) - Log in to Qdrant Cloud with a browser + diff --git a/docs/reference/qcloud_auth_status.md b/docs/reference/qcloud_auth_status.md new file mode 100644 index 0000000..5f3f9a3 --- /dev/null +++ b/docs/reference/qcloud_auth_status.md @@ -0,0 +1,45 @@ +## qcloud auth status + +Show OAuth login status for the current endpoint + +### Synopsis + +Show whether an OAuth session exists for the current endpoint. + +Prints the inferred login URL, token expiry, and scope. Does not print the +access token. + +``` +qcloud auth status [flags] +``` + +### Examples + +``` +qcloud auth status +qcloud auth status --endpoint grpc.staging-cloud.qdrant.io:443 +``` + +### Options + +``` + -h, --help help for status +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud auth](qcloud_auth.md) - Log in to Qdrant Cloud with a browser + diff --git a/docs/reference/qcloud_auth_token.md b/docs/reference/qcloud_auth_token.md new file mode 100644 index 0000000..ab586ac --- /dev/null +++ b/docs/reference/qcloud_auth_token.md @@ -0,0 +1,46 @@ +## qcloud auth token + +Print a fresh OAuth access token (sensitive) + +### Synopsis + +Print a fresh OAuth access token for the current endpoint. + +The token is sensitive. Use this for local Terraform (auth = "cli") or scripts +that need a Bearer token. Expired access tokens are refreshed when a refresh +token is stored. + +``` +qcloud auth token [flags] +``` + +### Examples + +``` +qcloud auth token +qcloud auth token --json +``` + +### Options + +``` + -h, --help help for token +``` + +### Options inherited from parent commands + +``` + --account-id string Qdrant Cloud Account ID (env: QDRANT_CLOUD_ACCOUNT_ID) + --api-key string Management API Key (env: QDRANT_CLOUD_API_KEY) + -c, --config string Config file path (env: QDRANT_CLOUD_CONFIG, default ~/.config/qcloud/config.yaml) + --console-url string Qdrant Cloud web console base URL (env: QDRANT_CLOUD_CONSOLE_URL, default https://cloud.qdrant.io) + --context string Override the active context (env: QDRANT_CLOUD_CONTEXT) + --debug Enable debug logging to stderr + --endpoint string gRPC API endpoint (env: QDRANT_CLOUD_ENDPOINT, default grpc.cloud.qdrant.io:443) + --json Output as JSON +``` + +### SEE ALSO + +* [qcloud auth](qcloud_auth.md) - Log in to Qdrant Cloud with a browser + diff --git a/internal/cli/root.go b/internal/cli/root.go index 9268521..bc02808 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/qdrant/qcloud-cli/internal/cmd/account" + "github.com/qdrant/qcloud-cli/internal/cmd/auth" "github.com/qdrant/qcloud-cli/internal/cmd/backup" "github.com/qdrant/qcloud-cli/internal/cmd/cloudprovider" "github.com/qdrant/qcloud-cli/internal/cmd/cloudregion" @@ -33,6 +34,7 @@ func NewRootCommand(s *state.State) *cobra.Command { Long: `The command-line interface for Qdrant Cloud. Get started: + qcloud auth login qcloud context set default --api-key --account-id qcloud cluster list @@ -73,6 +75,7 @@ Documentation: https://github.com/qdrant/qcloud-cli`, s.Config.BindPFlag(config.KeyConsoleURL, cmd.PersistentFlags().Lookup("console-url")) cmd.AddCommand(account.NewCommand(s)) + cmd.AddCommand(auth.NewCommand(s)) cmd.AddCommand(version.NewCommand(s)) cmd.AddCommand(iam.NewCommand(s)) cmd.AddCommand(cluster.NewCommand(s)) diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go new file mode 100644 index 0000000..2eb53d8 --- /dev/null +++ b/internal/cmd/auth/auth.go @@ -0,0 +1,47 @@ +package auth + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +// NewCommand creates the auth command group. +func NewCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Log in to Qdrant Cloud with a browser", + Long: `Log in to Qdrant Cloud with a browser (or a device code) instead of pasting an API key. + +The Auth0 login host is not a separate flag. It is derived from the gRPC +endpoint (flag --endpoint, env QDRANT_CLOUD_ENDPOINT, or the active context): + + grpc.cloud.qdrant.io:443 → login.cloud.qdrant.io + grpc.staging-cloud.qdrant.io:443 → login.staging-cloud.qdrant.io + grpc.development-cloud.qdrant.io:443 → login.development-cloud.qdrant.io + +When OAuth is enabled, the gateway also advertises the issuer at the +unauthenticated URL https://api..qdrant.io/.well-known/oauth-protected-resource. +That document wins over hostname inference. + +After login, API calls send Authorization: Bearer . Management API keys +keep working unchanged when --api-key / QDRANT_CLOUD_API_KEY is set.`, + Example: `# Discover the login URL for the current endpoint (no Auth0 app required) +qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443 + +# Browser login (authorization code + PKCE on 127.0.0.1) +qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 --client-id + +# Device code (SSH / no browser) +qcloud auth login --device --scope read-only`, + Args: cobra.NoArgs, + } + cmd.AddCommand( + newDiscoverCommand(s), + newLoginCommand(s), + newLogoutCommand(s), + newStatusCommand(s), + newTokenCommand(s), + ) + return cmd +} diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go new file mode 100644 index 0000000..51b73dd --- /dev/null +++ b/internal/cmd/auth/auth_test.go @@ -0,0 +1,179 @@ +package auth_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/qdrant/qcloud-cli/internal/state" + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func newEnv(t *testing.T) *testutil.TestEnv { + t.Helper() + return &testutil.TestEnv{ + State: state.New("test"), + Cleanup: func() {}, + } +} + +func TestAuthDiscover_InfersLoginFromEndpoint(t *testing.T) { + env := newEnv(t) + env.State.SetHTTPClient(&http.Client{Transport: errRoundTrip{}}) + + stdout, _, err := testutil.Exec(t, env, + "auth", "discover", + "--endpoint", "grpc.development-cloud.qdrant.io:443", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "grpc.development-cloud.qdrant.io:443") + assert.Contains(t, stdout, "https://api.development-cloud.qdrant.io") + assert.Contains(t, stdout, "https://login.development-cloud.qdrant.io/") + assert.Contains(t, stdout, "inferred from endpoint") +} + +func TestAuthDiscover_UsesGatewayMetadata(t *testing.T) { + env := newEnv(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/.well-known/oauth-protected-resource", r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]any{ + "resource": "https://api.staging-cloud.qdrant.io", + "authorization_servers": []string{"https://login.staging-cloud.qdrant.io/"}, + "scopes_supported": []string{"read-only", "manage"}, + }) + })) + t.Cleanup(srv.Close) + env.State.SetHTTPClient(rewriteClient(t, srv.URL)) + + stdout, _, err := testutil.Exec(t, env, + "auth", "discover", + "--endpoint", "grpc.staging-cloud.qdrant.io:443", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "https://login.staging-cloud.qdrant.io/") + assert.Contains(t, stdout, "protected-resource metadata") +} + +func TestAuthLogin_StoresToken(t *testing.T) { + env := newEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-protected-resource", 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", + }) + }) + 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 + } + + assert.Equal(t, "test-code", r.Form.Get("code")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access-from-login", + "refresh_token": "refresh-from-login", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "manage", + }) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + env.State.SetHTTPClient(rewriteClient(t, srv.URL)) + env.State.SetBrowserOpener(func(string) error { return nil }) + env.State.SetWaitAuthCode(func(context.Context, string, string) (string, error) { + return "test-code", nil + }, "http://127.0.0.1:1/callback") + + stdout, _, err := testutil.Exec(t, env, + "auth", "login", + "--endpoint", "grpc.development-cloud.qdrant.io:443", + "--client-id", "cli-app", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "Logged in") + + tokenOut, _, err := testutil.Exec(t, env, + "auth", "token", + "--endpoint", "grpc.development-cloud.qdrant.io:443", + ) + require.NoError(t, err) + assert.Equal(t, "access-from-login\n", tokenOut) + + statusOut, _, err := testutil.Exec(t, env, + "auth", "status", + "--endpoint", "grpc.development-cloud.qdrant.io:443", + ) + require.NoError(t, err) + assert.Contains(t, statusOut, "logged in") + + _, _, err = testutil.Exec(t, env, + "auth", "logout", + "--endpoint", "grpc.development-cloud.qdrant.io:443", + ) + require.NoError(t, err) + + _, _, err = testutil.Exec(t, env, + "auth", "token", + "--endpoint", "grpc.development-cloud.qdrant.io:443", + ) + require.Error(t, err) +} + +func TestAuthLogin_MissingClientID(t *testing.T) { + env := newEnv(t) + t.Setenv("QDRANT_CLOUD_OAUTH_CLIENT_ID", "") + env.State.SetHTTPClient(&http.Client{Transport: errRoundTrip{}}) + + _, _, err := testutil.Exec(t, env, + "auth", "login", + "--endpoint", "grpc.cloud.qdrant.io:443", + "--client-id", "", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "client id") +} + +type errRoundTrip struct{} + +func (errRoundTrip) RoundTrip(*http.Request) (*http.Response, error) { + return nil, io.ErrUnexpectedEOF +} + +func rewriteClient(t *testing.T, target string) *http.Client { + t.Helper() + u, err := url.Parse(target) + require.NoError(t, err) + return &http.Client{Transport: rewriteTransport{target: u}} +} + +type rewriteTransport struct { + target *url.URL +} + +func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = r.target.Scheme + clone.URL.Host = r.target.Host + clone.Host = r.target.Host + return http.DefaultTransport.RoundTrip(clone) +} diff --git a/internal/cmd/auth/discover.go b/internal/cmd/auth/discover.go new file mode 100644 index 0000000..cfe20a9 --- /dev/null +++ b/internal/cmd/auth/discover.go @@ -0,0 +1,51 @@ +package auth + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newDiscoverCommand(s *state.State) *cobra.Command { + return base.Cmd{ + Long: `Print the OAuth API resource and authorization-server issuer for the current endpoint. + +The gRPC endpoint is turned into https://api..qdrant.io. The CLI then +GETs /.well-known/oauth-protected-resource with no credentials. If the gateway +returns authorization_servers, that issuer is used; otherwise login. +is inferred. No Auth0 client id is required.`, + Example: `# Production (default endpoint) +qcloud auth discover + +# Development +qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443`, + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "discover", + Short: "Show the OAuth login URL inferred from the API endpoint", + Args: cobra.NoArgs, + } + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + disc, err := s.OAuthDiscoverer().Discover(cmd.Context(), s.Config.Endpoint()) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Endpoint: %s\n", disc.Hosts.GRPCEndpoint) + fmt.Fprintf(out, "API resource: %s\n", disc.Resource) + fmt.Fprintf(out, "Login URL: %s\n", disc.Issuer) + fmt.Fprintf(out, "Source: %s\n", disc.Source) + if len(disc.Scopes) > 0 { + fmt.Fprintf(out, "Scopes: %s\n", strings.Join(disc.Scopes, ", ")) + } + + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go new file mode 100644 index 0000000..0f2fd2d --- /dev/null +++ b/internal/cmd/auth/login.go @@ -0,0 +1,81 @@ +package auth + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/oauth" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newLoginCommand(s *state.State) *cobra.Command { + return base.Cmd{ + Long: `Log in through Auth0 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. + +The login URL comes from the gateway's unauthenticated protected-resource +metadata when OAuth is enabled, otherwise from rewriting grpc. to +login.. Override with --login-url only for debugging. + +The Auth0 native application ("Qdrant Cloud CLI") is not hard-coded yet. +Pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID after that app exists on +the tenant. Tokens are stored in credentials.yaml next to the config file +(mode 0600).`, + Example: `# Browser login against development +qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 --client-id + +# Read-only session +qcloud auth login --scope read-only --client-id + +# Device code +qcloud auth login --device --client-id `, + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Log in with a browser or device code", + Args: cobra.NoArgs, + } + cmd.Flags().String("scope", "manage", "OAuth scope: read-only or manage") + cmd.Flags().Bool("device", false, "Use the device authorization grant instead of a loopback browser flow") + cmd.Flags().String("client-id", os.Getenv("QDRANT_CLOUD_OAUTH_CLIENT_ID"), "Auth0 native app client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID)") + cmd.Flags().String("login-url", "", "Override the Auth0 issuer URL (defaults to discovery / endpoint inference)") + return cmd + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + scope, _ := cmd.Flags().GetString("scope") + device, _ := cmd.Flags().GetBool("device") + clientID, _ := cmd.Flags().GetString("client-id") + loginURL, _ := cmd.Flags().GetString("login-url") + + opts := oauth.LoginOptions{ + Endpoint: s.Config.Endpoint(), + ClientID: clientID, + Scope: scope, + Device: device, + LoginURL: loginURL, + WaitCode: s.WaitAuthCode(), + RedirectURL: s.OAuthRedirectURL(), + OpenURL: s.OpenBrowser, + Output: cmd.ErrOrStderr(), + } + + tok, disc, err := s.OAuthDiscoverer().Login(cmd.Context(), opts) + if err != nil { + return err + } + + path := oauth.CredentialsPath(s.Config.ConfigFilePath()) + if err := oauth.SaveToken(path, tok); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Logged in.\nLogin URL: %s (%s)\nResource: %s\nScope: %s\n", disc.Issuer, disc.Source, disc.Resource, tok.Scope) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/auth/logout.go b/internal/cmd/auth/logout.go new file mode 100644 index 0000000..48eaf01 --- /dev/null +++ b/internal/cmd/auth/logout.go @@ -0,0 +1,50 @@ +package auth + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/oauth" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newLogoutCommand(s *state.State) *cobra.Command { + return base.Cmd{ + Long: `Remove the stored OAuth token for the current endpoint. + +If the authorization server advertises a revocation endpoint, the refresh +token is revoked first. Management API keys in the config file are not +changed.`, + Example: `qcloud auth logout`, + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Log out and drop stored OAuth tokens", + Args: cobra.NoArgs, + } + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + path := oauth.CredentialsPath(s.Config.ConfigFilePath()) + endpoint := s.Config.Endpoint() + tok, ok, err := oauth.LoadToken(path, endpoint) + if err != nil { + return err + } + + if ok { + if err := s.OAuthDiscoverer().RevokeRefreshToken(cmd.Context(), tok); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: could not revoke refresh token: %v\n", err) + } + } + + if err := oauth.DeleteToken(path, endpoint); err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), "Logged out.") + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go new file mode 100644 index 0000000..c7f2fc8 --- /dev/null +++ b/internal/cmd/auth/status.go @@ -0,0 +1,64 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/oauth" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newStatusCommand(s *state.State) *cobra.Command { + return base.Cmd{ + Long: `Show whether an OAuth session exists for the current endpoint. + +Prints the inferred login URL, token expiry, and scope. Does not print the +access token.`, + Example: `qcloud auth status +qcloud auth status --endpoint grpc.staging-cloud.qdrant.io:443`, + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show OAuth login status for the current endpoint", + Args: cobra.NoArgs, + } + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + disc, err := s.OAuthDiscoverer().Discover(cmd.Context(), s.Config.Endpoint()) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Endpoint: %s\n", disc.Hosts.GRPCEndpoint) + fmt.Fprintf(out, "Login URL: %s (%s)\n", disc.Issuer, disc.Source) + + path := oauth.CredentialsPath(s.Config.ConfigFilePath()) + tok, ok, err := oauth.LoadToken(path, s.Config.Endpoint()) + if err != nil { + return err + } + + if !ok { + fmt.Fprintln(out, "Status: logged out") + return nil + } + + status := "logged in" + if tok.Expired(time.Now()) { + status = "expired (will refresh on next API call)" + } + + fmt.Fprintf(out, "Status: %s\n", status) + fmt.Fprintf(out, "Scope: %s\n", tok.Scope) + if !tok.Expiry.IsZero() { + fmt.Fprintf(out, "Expiry: %s\n", tok.Expiry.UTC().Format(time.RFC3339)) + } + + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/auth/token.go b/internal/cmd/auth/token.go new file mode 100644 index 0000000..1c2a971 --- /dev/null +++ b/internal/cmd/auth/token.go @@ -0,0 +1,63 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/oauth" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newTokenCommand(s *state.State) *cobra.Command { + return base.Cmd{ + Long: `Print a fresh OAuth access token for the current endpoint. + +The token is sensitive. Use this for local Terraform (auth = "cli") or scripts +that need a Bearer token. Expired access tokens are refreshed when a refresh +token is stored.`, + Example: `qcloud auth token +qcloud auth token --json`, + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "token", + Short: "Print a fresh OAuth access token (sensitive)", + Args: cobra.NoArgs, + } + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + path := oauth.CredentialsPath(s.Config.ConfigFilePath()) + tok, ok, err := oauth.LoadToken(path, s.Config.Endpoint()) + if err != nil { + return err + } + + if !ok { + return fmt.Errorf("not logged in — run \"qcloud auth login\"") + } + + if tok.Expired(time.Now()) { + refreshed, err := s.OAuthDiscoverer().Refresh(cmd.Context(), tok, time.Now()) + if err != nil { + return fmt.Errorf("refresh OAuth token: %w", err) + } + + if err := oauth.SaveToken(path, refreshed); err != nil { + return err + } + + tok = refreshed + } + + if s.Config.JSONOutput() { + fmt.Fprintf(cmd.OutOrStdout(), `{"access_token":%q,"token_type":%q,"scope":%q}`+"\n", tok.AccessToken, tok.TokenType, tok.Scope) + return nil + } + + fmt.Fprintln(cmd.OutOrStdout(), tok.AccessToken) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/oauth/device.go b/internal/oauth/device.go new file mode 100644 index 0000000..39fdba4 --- /dev/null +++ b/internal/oauth/device.go @@ -0,0 +1,171 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type deviceCodeResponse 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"` + ErrorDesc string `json:"error_description"` +} + +func (d Discoverer) deviceLogin(ctx context.Context, opts LoginOptions, disc Discovery, as ASMetadata, scope string) (Token, error) { + if as.DeviceAuthorizationEndpoint == "" { + return Token{}, fmt.Errorf("authorization server does not advertise device_authorization_endpoint") + } + + form := url.Values{ + "client_id": {opts.ClientID}, + "scope": {scope}, + "resource": {disc.Resource}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, as.DeviceAuthorizationEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return Token{}, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := d.client().Do(req) + if err != nil { + return Token{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return Token{}, fmt.Errorf("read device code response: %w", err) + } + + var dc deviceCodeResponse + if err := json.Unmarshal(body, &dc); err != nil { + return Token{}, fmt.Errorf("decode device code response: %w", err) + } + + if dc.Error != "" { + return Token{}, fmt.Errorf("device authorization: %s", strings.TrimSpace(dc.Error+" "+dc.ErrorDesc)) + } + + if dc.DeviceCode == "" || dc.UserCode == "" { + return Token{}, fmt.Errorf("device authorization: missing device_code or user_code") + } + + verify := dc.VerificationURIComplete + if verify == "" { + verify = dc.VerificationURI + } + + if opts.Output != nil { + fmt.Fprintf(opts.Output, "Open %s and enter code %s\n", verify, dc.UserCode) + } + + if opts.OpenURL != nil && verify != "" { + _ = opts.OpenURL(verify) + } + + interval := time.Duration(dc.Interval) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + + deadline := opts.Now().Add(10 * time.Minute) + if dc.ExpiresIn > 0 { + deadline = opts.Now().Add(time.Duration(dc.ExpiresIn) * time.Second) + } + + base := Token{ + Endpoint: disc.Hosts.GRPCEndpoint, + Issuer: disc.Issuer, + ClientID: opts.ClientID, + Resource: disc.Resource, + Scope: opts.Scope, + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + if opts.Now().After(deadline) { + return Token{}, fmt.Errorf("device login timed out") + } + + select { + case <-ctx.Done(): + return Token{}, ctx.Err() + case <-ticker.C: + } + + tokForm := url.Values{ + "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") { + return Token{}, err + } + } +} + +// RevokeRefreshToken calls the AS revocation endpoint when advertised. +func (d Discoverer) RevokeRefreshToken(ctx context.Context, tok Token) error { + if tok.RefreshToken == "" || tok.Issuer == "" { + return nil + } + + as, err := d.fetchASMetadata(ctx, tok.Issuer) + if err != nil { + return err + } + + if as.RevocationEndpoint == "" { + return nil + } + + form := url.Values{ + "token": {tok.RefreshToken}, + "token_type_hint": {"refresh_token"}, + "client_id": {tok.ClientID}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, as.RevocationEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := d.client().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("revoke: HTTP %s", resp.Status) + } + + return nil +} diff --git a/internal/oauth/discover.go b/internal/oauth/discover.go new file mode 100644 index 0000000..6224787 --- /dev/null +++ b/internal/oauth/discover.go @@ -0,0 +1,131 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + sourceProtectedResource = "protected-resource metadata" + sourceEndpointInference = "inferred from endpoint" +) + +// Discovery is the OAuth setup for one Cloud environment. +type Discovery struct { + Hosts Hosts + Resource string + Issuer string + Scopes []string + Source string +} + +// Discoverer resolves the authorization server for a gRPC endpoint. +type Discoverer struct { + HTTPClient *http.Client +} + +func (d Discoverer) client() *http.Client { + if d.HTTPClient != nil { + return d.HTTPClient + } + + return &http.Client{Timeout: 10 * time.Second} +} + +// Discover maps endpoint → API host, then prefers the gateway's unauthenticated +// PRM document for the issuer. If OAuth is not enabled yet, it infers login.. +func (d Discoverer) Discover(ctx context.Context, endpoint string) (Discovery, error) { + hosts, err := ParseHosts(endpoint) + if err != nil { + return Discovery{}, err + } + + out := Discovery{ + Hosts: hosts, + Resource: hosts.Resource(), + Issuer: hosts.InferredIssuer(), + Scopes: []string{"read-only", "manage"}, + Source: sourceEndpointInference, + } + + prm, ok := d.tryPRM(ctx, hosts.ProtectedResourceMetadataURL()) + if !ok { + // OAuth may not be enabled yet; hostname inference still works. + return out, nil + } + + if len(prm.AuthorizationServers) > 0 && prm.AuthorizationServers[0] != "" { + out.Issuer = canonicalizeIssuer(prm.AuthorizationServers[0]) + out.Source = sourceProtectedResource + } + + if prm.Resource != "" { + out.Resource = strings.TrimRight(prm.Resource, "/") + } + + if len(prm.ScopesSupported) > 0 { + out.Scopes = prm.ScopesSupported + } + + return out, nil +} + +type prmDocument struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + ScopesSupported []string `json:"scopes_supported"` +} + +func (d Discoverer) tryPRM(ctx context.Context, url string) (prmDocument, bool) { + doc, err := d.fetchPRM(ctx, url) + if err != nil { + return prmDocument{}, false + } + + return doc, true +} + +func (d Discoverer) fetchPRM(ctx context.Context, url string) (prmDocument, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return prmDocument{}, err + } + + req.Header.Set("Accept", "application/json") + + resp, err := d.client().Do(req) + if err != nil { + return prmDocument{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + return prmDocument{}, fmt.Errorf("protected-resource metadata: HTTP %s", resp.Status) + } + + var doc prmDocument + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return prmDocument{}, fmt.Errorf("decode protected-resource metadata: %w", err) + } + + return doc, nil +} + +func canonicalizeIssuer(issuer string) string { + issuer = strings.TrimSpace(issuer) + if issuer == "" { + return issuer + } + + if !strings.HasSuffix(issuer, "/") { + return issuer + "/" + } + + return issuer +} diff --git a/internal/oauth/discover_test.go b/internal/oauth/discover_test.go new file mode 100644 index 0000000..7292eb5 --- /dev/null +++ b/internal/oauth/discover_test.go @@ -0,0 +1,53 @@ +package oauth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscover_FallsBackToInferredIssuer(t *testing.T) { + t.Parallel() + + d := Discoverer{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, assert.AnError + })}} + + disc, err := d.Discover(context.Background(), "grpc.development-cloud.qdrant.io:443") + require.NoError(t, err) + assert.Equal(t, "https://login.development-cloud.qdrant.io/", disc.Issuer) + assert.Equal(t, "https://api.development-cloud.qdrant.io", disc.Resource) + assert.Equal(t, sourceEndpointInference, disc.Source) +} + +func TestDiscover_UsesProtectedResourceMetadata(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, wellKnownPRM, r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]any{ + "resource": "https://api.development-cloud.qdrant.io", + "authorization_servers": []string{"https://login.development-cloud.qdrant.io/"}, + "scopes_supported": []string{"read-only", "manage"}, + }) + })) + t.Cleanup(srv.Close) + + d := Discoverer{HTTPClient: rewriteClient(t, srv.URL)} + disc, err := d.Discover(context.Background(), "grpc.development-cloud.qdrant.io:443") + require.NoError(t, err) + assert.Equal(t, "https://login.development-cloud.qdrant.io/", disc.Issuer) + assert.Equal(t, sourceProtectedResource, disc.Source) + assert.Equal(t, []string{"read-only", "manage"}, disc.Scopes) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} diff --git a/internal/oauth/doc.go b/internal/oauth/doc.go new file mode 100644 index 0000000..7e23ac9 --- /dev/null +++ b/internal/oauth/doc.go @@ -0,0 +1,9 @@ +// Package oauth implements Qdrant Cloud CLI browser/device login. +// +// The Auth0 issuer is not a separate config field. It is taken from +// QDRANT_CLOUD_ENDPOINT (grpc..qdrant.io) by: +// 1. mapping the gRPC host to the HTTPS API host (grpc. → api.) +// 2. fetching unauthenticated RFC 9728 protected-resource metadata +// 3. falling back to login..qdrant.io when the gateway does not +// advertise OAuth yet +package oauth diff --git a/internal/oauth/endpoint.go b/internal/oauth/endpoint.go new file mode 100644 index 0000000..e5fcea5 --- /dev/null +++ b/internal/oauth/endpoint.go @@ -0,0 +1,93 @@ +package oauth + +import ( + "fmt" + "net" + "strings" +) + +const ( + defaultGRPCHost = "grpc.cloud.qdrant.io" + wellKnownPRM = "/.well-known/oauth-protected-resource" +) + +// Hosts are the HTTPS API and Auth0 issuer derived from a gRPC endpoint. +type Hosts struct { + GRPCEndpoint string + APIHost string + LoginHost string +} + +// APIBaseURL is https://api..qdrant.io (no trailing slash). +func (h Hosts) APIBaseURL() string { + return "https://" + h.APIHost +} + +// Resource is the generic Cloud API OAuth resource identifier (JWT aud). +func (h Hosts) Resource() string { + return h.APIBaseURL() +} + +// InferredIssuer is https://login..qdrant.io/ (trailing slash as Auth0 issues it). +func (h Hosts) InferredIssuer() string { + return "https://" + h.LoginHost + "/" +} + +// ProtectedResourceMetadataURL is the unauthenticated RFC 9728 document on the API host. +func (h Hosts) ProtectedResourceMetadataURL() string { + return h.APIBaseURL() + wellKnownPRM +} + +// ParseHosts maps a gRPC endpoint (QDRANT_CLOUD_ENDPOINT) to API and login hosts. +// grpc.cloud.qdrant.io:443 → api.cloud.qdrant.io / login.cloud.qdrant.io. +func ParseHosts(endpoint string) (Hosts, error) { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + endpoint = defaultGRPCHost + ":443" + } + + host := endpoint + if strings.Contains(endpoint, "://") { + return Hosts{}, fmt.Errorf("endpoint %q looks like a URL; use a gRPC host:port (e.g. grpc.cloud.qdrant.io:443)", endpoint) + } + + if h, _, err := net.SplitHostPort(endpoint); err == nil { + host = h + } + + host = strings.TrimSuffix(host, ".") + if host == "" { + return Hosts{}, fmt.Errorf("empty host in endpoint %q", endpoint) + } + + apiHost, ok := rewriteFirstLabel(host, "api") + if !ok { + return Hosts{}, fmt.Errorf("cannot infer API/login hosts from endpoint %q; expected grpc..qdrant.io", endpoint) + } + + loginHost, ok := rewriteFirstLabel(host, "login") + if !ok { + return Hosts{}, fmt.Errorf("cannot infer login host from endpoint %q", endpoint) + } + + return Hosts{ + GRPCEndpoint: endpoint, + APIHost: apiHost, + LoginHost: loginHost, + }, nil +} + +func rewriteFirstLabel(host, newLabel string) (string, bool) { + labels := strings.Split(host, ".") + if len(labels) < 2 { + return "", false + } + + switch labels[0] { + case "grpc", "api", "login": + labels[0] = newLabel + return strings.Join(labels, "."), true + default: + return "", false + } +} diff --git a/internal/oauth/endpoint_test.go b/internal/oauth/endpoint_test.go new file mode 100644 index 0000000..9fd0ac1 --- /dev/null +++ b/internal/oauth/endpoint_test.go @@ -0,0 +1,53 @@ +package oauth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseHosts(t *testing.T) { + t.Parallel() + + tests := []struct { + endpoint string + api string + login string + }{ + {"grpc.cloud.qdrant.io:443", "api.cloud.qdrant.io", "login.cloud.qdrant.io"}, + {"grpc.staging-cloud.qdrant.io:443", "api.staging-cloud.qdrant.io", "login.staging-cloud.qdrant.io"}, + {"grpc.development-cloud.qdrant.io:443", "api.development-cloud.qdrant.io", "login.development-cloud.qdrant.io"}, + {"grpc.cloud.qdrant.io", "api.cloud.qdrant.io", "login.cloud.qdrant.io"}, + {"api.staging-cloud.qdrant.io:443", "api.staging-cloud.qdrant.io", "login.staging-cloud.qdrant.io"}, + {"", "api.cloud.qdrant.io", "login.cloud.qdrant.io"}, + } + + for _, tt := range tests { + t.Run(tt.endpoint, func(t *testing.T) { + t.Parallel() + h, err := ParseHosts(tt.endpoint) + require.NoError(t, err) + assert.Equal(t, tt.api, h.APIHost) + assert.Equal(t, tt.login, h.LoginHost) + assert.Equal(t, "https://"+tt.api, h.APIBaseURL()) + assert.Equal(t, "https://"+tt.login+"/", h.InferredIssuer()) + }) + } +} + +func TestParseHosts_RejectsURL(t *testing.T) { + t.Parallel() + + _, err := ParseHosts("https://grpc.cloud.qdrant.io:443") + require.Error(t, err) + assert.Contains(t, err.Error(), "looks like a URL") +} + +func TestParseHosts_RejectsUnknownHost(t *testing.T) { + t.Parallel() + + _, err := ParseHosts("localhost:50051") + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot infer") +} diff --git a/internal/oauth/httptest_helpers_test.go b/internal/oauth/httptest_helpers_test.go new file mode 100644 index 0000000..b36a24b --- /dev/null +++ b/internal/oauth/httptest_helpers_test.go @@ -0,0 +1,31 @@ +package oauth + +import ( + "net/http" + "net/url" + "testing" +) + +func rewriteClient(t *testing.T, target string) *http.Client { + t.Helper() + + u, err := url.Parse(target) + if err != nil { + t.Fatal(err) + } + + return &http.Client{Transport: rewriteTransport{target: u, base: http.DefaultTransport}} +} + +type rewriteTransport struct { + target *url.URL + base http.RoundTripper +} + +func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = r.target.Scheme + clone.URL.Host = r.target.Host + clone.Host = r.target.Host + return r.base.RoundTrip(clone) +} diff --git a/internal/oauth/login.go b/internal/oauth/login.go new file mode 100644 index 0000000..47edc4b --- /dev/null +++ b/internal/oauth/login.go @@ -0,0 +1,242 @@ +package oauth + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "slices" + "strings" + "time" +) + +const ( + defaultScope = "manage" + offlineScope = "offline_access" +) + +// WaitCodeFunc waits for an authorization code at redirectURL (loopback). +type WaitCodeFunc func(ctx context.Context, redirectURL, state string) (code string, err error) + +// LoginOptions configure authorization-code or device login. +type LoginOptions struct { + Endpoint string + ClientID string + Scope string + Device bool + LoginURL string + Resource string + Now func() time.Time + WaitCode WaitCodeFunc + RedirectURL string + OpenURL func(string) error + Output io.Writer + Listen func() (net.Listener, error) +} + +// Login runs the OAuth flow and returns a token ready to store. +func (d Discoverer) Login(ctx context.Context, opts LoginOptions) (Token, Discovery, error) { + if opts.Now == nil { + opts.Now = time.Now + } + + if opts.Scope == "" { + opts.Scope = defaultScope + } + + disc, err := d.Discover(ctx, opts.Endpoint) + if err != nil { + return Token{}, Discovery{}, err + } + + if opts.LoginURL != "" { + disc.Issuer = canonicalizeIssuer(opts.LoginURL) + disc.Source = "flag --login-url" + } + + if opts.Resource != "" { + disc.Resource = strings.TrimRight(opts.Resource, "/") + } + + if opts.ClientID == "" { + return Token{}, disc, fmt.Errorf("OAuth client id is required (--client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID) once the Qdrant Cloud CLI Auth0 app exists for this tenant") + } + + as, err := d.fetchASMetadata(ctx, disc.Issuer) + if err != nil { + return Token{}, disc, err + } + + scope := joinScopes(opts.Scope) + + if opts.Device { + tok, err := d.deviceLogin(ctx, opts, disc, as, scope) + return tok, disc, err + } + + tok, err := d.codeLogin(ctx, opts, disc, as, scope) + return tok, disc, err +} + +func joinScopes(scope string) string { + parts := strings.Fields(scope) + hasOffline := slices.Contains(parts, offlineScope) + + if !hasOffline { + parts = append(parts, offlineScope) + } + + return strings.Join(parts, " ") +} + +func (d Discoverer) codeLogin(ctx context.Context, opts LoginOptions, disc Discovery, as ASMetadata, scope string) (Token, error) { + wait := opts.WaitCode + redirectURL := opts.RedirectURL + + if wait == nil { + ln, err := listenLoopback(opts.Listen) + if err != nil { + return Token{}, err + } + defer ln.Close() + + redirectURL = "http://" + ln.Addr().String() + "/callback" + wait = loopbackWaiter(ln, opts.Output) + } else if redirectURL == "" { + redirectURL = "http://127.0.0.1:1/callback" + } + + state, err := randomURLString(16) + if err != nil { + return Token{}, err + } + + verifier, err := randomURLString(32) + if err != nil { + return Token{}, err + } + + q := url.Values{ + "response_type": {"code"}, + "client_id": {opts.ClientID}, + "redirect_uri": {redirectURL}, + "scope": {scope}, + "state": {state}, + "code_challenge": {pkceChallenge(verifier)}, + "code_challenge_method": {"S256"}, + "resource": {disc.Resource}, + } + authorizeURL := as.AuthorizationEndpoint + "?" + q.Encode() + + if opts.OpenURL != nil { + if err := opts.OpenURL(authorizeURL); err != nil { + return Token{}, fmt.Errorf("open browser: %w", err) + } + } + + if opts.Output != nil { + fmt.Fprintf(opts.Output, "Opening browser for login.\nIf it does not open, visit:\n%s\n", authorizeURL) + } + + code, err := wait(ctx, redirectURL, state) + if err != nil { + return Token{}, err + } + + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURL}, + "client_id": {opts.ClientID}, + "code_verifier": {verifier}, + "resource": {disc.Resource}, + } + + base := Token{ + Endpoint: disc.Hosts.GRPCEndpoint, + Issuer: disc.Issuer, + ClientID: opts.ClientID, + Resource: disc.Resource, + Scope: opts.Scope, + } + + return d.exchange(ctx, as.TokenEndpoint, form, opts.Now(), base) +} + +func listenLoopback(listen func() (net.Listener, error)) (net.Listener, error) { + if listen != nil { + return listen() + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen on loopback: %w", err) + } + + return ln, nil +} + +func loopbackWaiter(ln net.Listener, out io.Writer) 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") + return + } + + fmt.Fprint(w, "You can close this window and return to the CLI.") + codeCh <- code + }) + + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + if out != nil { + fmt.Fprintf(out, "Waiting for login to complete on %s\n", ln.Addr().String()) + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case err := <-errCh: + return "", err + case code := <-codeCh: + return code, nil + } + } +} diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go new file mode 100644 index 0000000..1cc3eac --- /dev/null +++ b/internal/oauth/login_test.go @@ -0,0 +1,82 @@ +package oauth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogin_AuthorizationCode(t *testing.T) { + t.Parallel() + + 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", + }) + }) + 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 + } + + assert.Equal(t, "authorization_code", r.Form.Get("grant_type")) + assert.Equal(t, "test-code", r.Form.Get("code")) + assert.Equal(t, "cli-client", r.Form.Get("client_id")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at-1", + "refresh_token": "rt-1", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "manage offline_access", + }) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + d := Discoverer{HTTPClient: rewriteClient(t, srv.URL)} + tok, disc, err := d.Login(context.Background(), LoginOptions{ + Endpoint: "grpc.development-cloud.qdrant.io:443", + ClientID: "cli-client", + Scope: "manage", + RedirectURL: "http://127.0.0.1:1/callback", + WaitCode: func(context.Context, string, string) (string, error) { + return "test-code", nil + }, + OpenURL: func(string) error { return nil }, + Output: io.Discard, + }) + require.NoError(t, err) + assert.Equal(t, "at-1", tok.AccessToken) + assert.Equal(t, "rt-1", tok.RefreshToken) + assert.Equal(t, sourceProtectedResource, disc.Source) +} + +func TestLogin_RequiresClientID(t *testing.T) { + t.Parallel() + + d := Discoverer{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, assert.AnError + })}} + _, _, err := d.Login(context.Background(), LoginOptions{ + Endpoint: "grpc.cloud.qdrant.io:443", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "client id") +} diff --git a/internal/oauth/oidc.go b/internal/oauth/oidc.go new file mode 100644 index 0000000..f4639f1 --- /dev/null +++ b/internal/oauth/oidc.go @@ -0,0 +1,57 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// ASMetadata is the subset of OIDC / OAuth AS metadata the CLI needs. +type ASMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + RevocationEndpoint string `json:"revocation_endpoint"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +func (d Discoverer) fetchASMetadata(ctx context.Context, issuer string) (ASMetadata, error) { + metaURL, err := url.JoinPath(strings.TrimRight(issuer, "/")+"/", ".well-known/openid-configuration") + if err != nil { + return ASMetadata{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) + if err != nil { + return ASMetadata{}, err + } + + req.Header.Set("Accept", "application/json") + + resp, err := d.client().Do(req) + if err != nil { + return ASMetadata{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + return ASMetadata{}, fmt.Errorf("authorization server metadata: HTTP %s", resp.Status) + } + + var meta ASMetadata + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return ASMetadata{}, fmt.Errorf("decode authorization server metadata: %w", err) + } + + if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { + return ASMetadata{}, fmt.Errorf("authorization server metadata missing authorization or token endpoint") + } + + return meta, nil +} diff --git a/internal/oauth/pkce.go b/internal/oauth/pkce.go new file mode 100644 index 0000000..96217f8 --- /dev/null +++ b/internal/oauth/pkce.go @@ -0,0 +1,22 @@ +package oauth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +func randomURLString(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func pkceChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/internal/oauth/store.go b/internal/oauth/store.go new file mode 100644 index 0000000..2790279 --- /dev/null +++ b/internal/oauth/store.go @@ -0,0 +1,142 @@ +package oauth + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +const credentialsFileName = "credentials.yaml" + +// Token is a stored OAuth session for one gRPC endpoint. +type Token struct { + Endpoint string `yaml:"endpoint"` + AccessToken string `yaml:"access_token"` + RefreshToken string `yaml:"refresh_token,omitempty"` + TokenType string `yaml:"token_type,omitempty"` + Expiry time.Time `yaml:"expiry,omitempty"` + Scope string `yaml:"scope,omitempty"` + Issuer string `yaml:"issuer,omitempty"` + ClientID string `yaml:"client_id,omitempty"` + Resource string `yaml:"resource,omitempty"` +} + +// Expired reports whether the access token should be refreshed. +func (t Token) Expired(now time.Time) bool { + if t.Expiry.IsZero() { + return false + } + + return !t.Expiry.After(now.Add(30 * time.Second)) +} + +type credentialsFile struct { + Tokens []Token `yaml:"tokens,omitempty"` +} + +// CredentialsPath is credentials.yaml next to the CLI config file. +func CredentialsPath(configFilePath string) string { + dir := filepath.Join(os.Getenv("HOME"), ".config", "qcloud") + if configFilePath != "" { + dir = filepath.Dir(configFilePath) + } else if home, err := os.UserHomeDir(); err == nil { + dir = filepath.Join(home, ".config", "qcloud") + } + + return filepath.Join(dir, credentialsFileName) +} + +// LoadToken returns the stored token for endpoint, or false if none. +func LoadToken(path, endpoint string) (Token, bool, error) { + file, err := readCredentials(path) + if err != nil { + return Token{}, false, err + } + + for _, tok := range file.Tokens { + if tok.Endpoint == endpoint { + return tok, true, nil + } + } + + return Token{}, false, nil +} + +// SaveToken upserts a token for its endpoint. +func SaveToken(path string, tok Token) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("creating credentials dir: %w", err) + } + + file, err := readCredentials(path) + if err != nil { + return err + } + + replaced := false + for i, existing := range file.Tokens { + if existing.Endpoint == tok.Endpoint { + file.Tokens[i] = tok + replaced = true + break + } + } + + if !replaced { + file.Tokens = append(file.Tokens, tok) + } + + return writeCredentials(path, file) +} + +// DeleteToken removes the stored token for endpoint. +func DeleteToken(path, endpoint string) error { + file, err := readCredentials(path) + if err != nil { + return err + } + + filtered := file.Tokens[:0] + for _, tok := range file.Tokens { + if tok.Endpoint != endpoint { + filtered = append(filtered, tok) + } + } + + file.Tokens = filtered + return writeCredentials(path, file) +} + +func readCredentials(path string) (credentialsFile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return credentialsFile{}, nil + } + + return credentialsFile{}, fmt.Errorf("reading credentials: %w", err) + } + + var file credentialsFile + if err := yaml.Unmarshal(data, &file); err != nil { + return credentialsFile{}, fmt.Errorf("parsing credentials: %w", err) + } + + return file, nil +} + +func writeCredentials(path string, file credentialsFile) error { + data, err := yaml.Marshal(file) + if err != nil { + return fmt.Errorf("marshaling credentials: %w", err) + } + + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("writing credentials: %w", err) + } + + return nil +} diff --git a/internal/oauth/store_test.go b/internal/oauth/store_test.go new file mode 100644 index 0000000..12c3c1e --- /dev/null +++ b/internal/oauth/store_test.go @@ -0,0 +1,45 @@ +package oauth + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenStore_RoundTrip(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "credentials.yaml") + tok := Token{ + Endpoint: "grpc.development-cloud.qdrant.io:443", + AccessToken: "access", + RefreshToken: "refresh", + Scope: "manage", + Expiry: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC), + } + + require.NoError(t, SaveToken(path, tok)) + + got, ok, err := LoadToken(path, tok.Endpoint) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "access", got.AccessToken) + assert.Equal(t, "refresh", got.RefreshToken) + + require.NoError(t, DeleteToken(path, tok.Endpoint)) + _, ok, err = LoadToken(path, tok.Endpoint) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestToken_Expired(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + assert.True(t, Token{Expiry: now.Add(10 * time.Second)}.Expired(now)) + assert.False(t, Token{Expiry: now.Add(2 * time.Minute)}.Expired(now)) + assert.False(t, Token{}.Expired(now)) +} diff --git a/internal/oauth/token.go b/internal/oauth/token.go new file mode 100644 index 0000000..6e76535 --- /dev/null +++ b/internal/oauth/token.go @@ -0,0 +1,109 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +func (d Discoverer) exchange(ctx context.Context, tokenURL string, form url.Values, now time.Time, meta Token) (Token, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return Token{}, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := d.client().Do(req) + if err != nil { + return Token{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return Token{}, fmt.Errorf("read token response: %w", err) + } + + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return Token{}, fmt.Errorf("decode token response: %w", err) + } + + if tr.Error != "" { + desc := tr.ErrorDesc + if desc == "" { + desc = tr.Error + } + + return Token{}, fmt.Errorf("token endpoint: %s", desc) + } + + if resp.StatusCode != http.StatusOK { + return Token{}, fmt.Errorf("token endpoint: HTTP %s", resp.Status) + } + + if tr.AccessToken == "" { + return Token{}, fmt.Errorf("token endpoint returned no access_token") + } + + tok := meta + tok.AccessToken = tr.AccessToken + if tr.RefreshToken != "" { + tok.RefreshToken = tr.RefreshToken + } + + tok.TokenType = tr.TokenType + if tok.TokenType == "" { + tok.TokenType = "Bearer" + } + + if tr.Scope != "" { + tok.Scope = tr.Scope + } + + if tr.ExpiresIn > 0 { + tok.Expiry = now.Add(time.Duration(tr.ExpiresIn) * time.Second) + } + + return tok, nil +} + +// Refresh exchanges a refresh token for a new access token. +func (d Discoverer) Refresh(ctx context.Context, tok Token, now time.Time) (Token, error) { + if tok.RefreshToken == "" { + return Token{}, fmt.Errorf("no refresh token; run qcloud auth login") + } + + meta, err := d.fetchASMetadata(ctx, tok.Issuer) + if err != nil { + return Token{}, err + } + + form := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {tok.RefreshToken}, + "client_id": {tok.ClientID}, + } + if tok.Resource != "" { + form.Set("resource", tok.Resource) + } + + return d.exchange(ctx, meta.TokenEndpoint, form, now, tok) +} diff --git a/internal/qcloudapi/client.go b/internal/qcloudapi/client.go index 3f2e3aa..eefe9e5 100644 --- a/internal/qcloudapi/client.go +++ b/internal/qcloudapi/client.go @@ -56,10 +56,27 @@ func New(ctx context.Context, endpoint, apiKey, version string) (*Client, error) ) } +// NewWithBearer creates a client that sends Authorization: Bearer . +func NewWithBearer(ctx context.Context, endpoint, accessToken, version string) (*Client, error) { + return newWithAuthorization(endpoint, "Bearer "+accessToken, + grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), + grpc.WithUserAgent("qcloud-cli/"+version), + ) +} + // NewWithDialOptions creates a Client with the auth interceptor always applied, // plus any additional dial options (e.g. custom transport for testing). func NewWithDialOptions(endpoint, apiKey string, opts ...grpc.DialOption) (*Client, error) { - all := append([]grpc.DialOption{grpc.WithUnaryInterceptor(authInterceptor(apiKey))}, opts...) + header := "" + if apiKey != "" { + header = "apikey " + apiKey + } + + return newWithAuthorization(endpoint, header, opts...) +} + +func newWithAuthorization(endpoint, authorization string, opts ...grpc.DialOption) (*Client, error) { + all := append([]grpc.DialOption{grpc.WithUnaryInterceptor(authInterceptor(authorization))}, opts...) conn, err := grpc.NewClient(endpoint, all...) if err != nil { return nil, err @@ -177,7 +194,7 @@ func (c *Client) Close() error { const traceIDTrailer = "qc-trace-id" -func authInterceptor(apiKey string) grpc.UnaryClientInterceptor { +func authInterceptor(authorization string) grpc.UnaryClientInterceptor { return func( ctx context.Context, method string, @@ -188,7 +205,10 @@ func authInterceptor(apiKey string) grpc.UnaryClientInterceptor { ) error { var trailer metadata.MD opts = append(opts, grpc.Trailer(&trailer)) - ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "apikey "+apiKey) + if authorization != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", authorization) + } + err := invoker(ctx, method, req, reply, cc, opts...) if err != nil { if ids := trailer.Get(traceIDTrailer); len(ids) > 0 { diff --git a/internal/qcloudapi/client_test.go b/internal/qcloudapi/client_test.go index 3952f23..3db0a13 100644 --- a/internal/qcloudapi/client_test.go +++ b/internal/qcloudapi/client_test.go @@ -12,7 +12,7 @@ import ( ) func TestAuthInterceptor_TraceID(t *testing.T) { - interceptor := authInterceptor("test-key") + interceptor := authInterceptor("apikey test-key") t.Run("no error returns nil", func(t *testing.T) { invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { @@ -75,3 +75,15 @@ func TestAuthInterceptor_TraceID(t *testing.T) { assert.NoError(t, err) }) } + +func TestAuthInterceptor_Bearer(t *testing.T) { + interceptor := authInterceptor("Bearer access-token") + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + md, ok := metadata.FromOutgoingContext(ctx) + assert.True(t, ok) + assert.Equal(t, []string{"Bearer access-token"}, md.Get("authorization")) + return nil + } + err := interceptor(context.Background(), "/test", nil, nil, nil, invoker) + assert.NoError(t, err) +} diff --git a/internal/state/state.go b/internal/state/state.go index 9f213bc..d3a8089 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -5,16 +5,19 @@ import ( "errors" "fmt" "log/slog" + "net/http" + "time" "github.com/pkg/browser" + "github.com/qdrant/qcloud-cli/internal/oauth" "github.com/qdrant/qcloud-cli/internal/qcloudapi" "github.com/qdrant/qcloud-cli/internal/selfupgrade" "github.com/qdrant/qcloud-cli/internal/state/config" ) var ( - errNoAPIKey = errors.New("no API Key configured — set QDRANT_CLOUD_API_KEY, use --api-key, or run \"qcloud context set\" to save credentials") + errNoAPIKey = errors.New("no API Key configured — set QDRANT_CLOUD_API_KEY, use --api-key, run \"qcloud auth login\", or run \"qcloud context set\" to save credentials") errNoAccountID = errors.New("no account ID configured — set QDRANT_CLOUD_ACCOUNT_ID, use --account-id, or run \"qcloud context set\" to save credentials") ) @@ -36,6 +39,9 @@ type State struct { unAuthenticatedClient *qcloudapi.Client updater Updater openBrowser BrowserOpener + httpClient *http.Client + waitAuthCode oauth.WaitCodeFunc + oauthRedirectURL string } // New creates a new State with the given version string. @@ -54,11 +60,22 @@ func (s *State) Client(ctx context.Context) (*qcloudapi.Client, error) { } key := s.Config.APIKey() - if key == "" { - return nil, errNoAPIKey + if key != "" { + c, err := qcloudapi.New(ctx, s.Config.Endpoint(), key, s.Version) + if err != nil { + return nil, fmt.Errorf("failed to connect to Qdrant Cloud API: %w", err) + } + + s.client = c + return s.client, nil + } + + tok, err := s.oauthAccessToken(ctx) + if err != nil { + return nil, err } - c, err := qcloudapi.New(ctx, s.Config.Endpoint(), key, s.Version) + c, err := qcloudapi.NewWithBearer(ctx, s.Config.Endpoint(), tok, s.Version) if err != nil { return nil, fmt.Errorf("failed to connect to Qdrant Cloud API: %w", err) } @@ -106,6 +123,68 @@ func (s *State) SetBrowserOpener(fn BrowserOpener) { s.openBrowser = fn } +// HTTPClient returns the HTTP client used for OAuth discovery and token calls. +func (s *State) HTTPClient() *http.Client { + if s.httpClient != nil { + return s.httpClient + } + + return http.DefaultClient +} + +// SetHTTPClient injects an HTTP client. For testing. +func (s *State) SetHTTPClient(c *http.Client) { + s.httpClient = c +} + +// SetWaitAuthCode injects a function that supplies the OAuth authorization code. For testing. +func (s *State) SetWaitAuthCode(fn oauth.WaitCodeFunc, redirectURL string) { + s.waitAuthCode = fn + s.oauthRedirectURL = redirectURL +} + +// OAuthDiscoverer returns a Discoverer bound to this state's HTTP client. +func (s *State) OAuthDiscoverer() oauth.Discoverer { + return oauth.Discoverer{HTTPClient: s.HTTPClient()} +} + +// WaitAuthCode is the injected authorization-code waiter, or nil for loopback. +func (s *State) WaitAuthCode() oauth.WaitCodeFunc { + return s.waitAuthCode +} + +// OAuthRedirectURL is the injected loopback redirect used with WaitAuthCode. +func (s *State) OAuthRedirectURL() string { + return s.oauthRedirectURL +} + +func (s *State) oauthAccessToken(ctx context.Context) (string, error) { + path := oauth.CredentialsPath(s.Config.ConfigFilePath()) + tok, ok, err := oauth.LoadToken(path, s.Config.Endpoint()) + if err != nil { + return "", err + } + + if !ok || tok.AccessToken == "" { + return "", errNoAPIKey + } + + if tok.Expired(time.Now()) { + refreshed, err := s.OAuthDiscoverer().Refresh(ctx, tok, time.Now()) + if err != nil { + return "", fmt.Errorf("refresh OAuth token: %w (run \"qcloud auth login\")", err) + } + + if err := oauth.SaveToken(path, refreshed); err != nil { + return "", err + } + + tok = refreshed + } + + return tok.AccessToken, nil +} + // Updater returns the CLI updater, creating it lazily on first call. func (s *State) Updater() (Updater, error) { if s.updater != nil { From a9dfe4507c8e6dbe3ba409e80f0e9fa483927fe8 Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:51:03 +0000 Subject: [PATCH 02/25] docs(auth): keep public login docs production-only Present browser login and API keys as alternatives, drop Auth0 and non-prod hosts from generated help. --- docs/reference/qcloud.md | 5 ++++- docs/reference/qcloud_auth.md | 14 ++++++-------- docs/reference/qcloud_auth_discover.md | 13 +++++-------- docs/reference/qcloud_auth_login.md | 21 ++++++++++----------- docs/reference/qcloud_auth_status.md | 1 - internal/cli/root.go | 5 ++++- internal/cmd/auth/auth.go | 14 ++++++-------- internal/cmd/auth/discover.go | 15 ++++++--------- internal/cmd/auth/login.go | 21 ++++++++++----------- internal/cmd/auth/status.go | 3 +-- internal/oauth/login.go | 2 +- 11 files changed, 53 insertions(+), 61 deletions(-) diff --git a/docs/reference/qcloud.md b/docs/reference/qcloud.md index 13e26e9..4fbfcec 100644 --- a/docs/reference/qcloud.md +++ b/docs/reference/qcloud.md @@ -6,9 +6,12 @@ Qdrant Cloud CLI The command-line interface for Qdrant Cloud. -Get started: +Get started — choose one of: + qcloud auth login qcloud context set default --api-key --account-id + +Then: qcloud cluster list Documentation: https://github.com/qdrant/qcloud-cli diff --git a/docs/reference/qcloud_auth.md b/docs/reference/qcloud_auth.md index 6487a50..f71a515 100644 --- a/docs/reference/qcloud_auth.md +++ b/docs/reference/qcloud_auth.md @@ -6,15 +6,13 @@ Log in to Qdrant Cloud with a browser Log in to Qdrant Cloud with a browser (or a device code) instead of pasting an API key. -The Auth0 login host is not a separate flag. It is derived from the gRPC +The login host is not a separate flag. It is derived from the gRPC endpoint (flag --endpoint, env QDRANT_CLOUD_ENDPOINT, or the active context): - grpc.cloud.qdrant.io:443 → login.cloud.qdrant.io - grpc.staging-cloud.qdrant.io:443 → login.staging-cloud.qdrant.io - grpc.development-cloud.qdrant.io:443 → login.development-cloud.qdrant.io + grpc.cloud.qdrant.io:443 → login.cloud.qdrant.io When OAuth is enabled, the gateway also advertises the issuer at the -unauthenticated URL https://api..qdrant.io/.well-known/oauth-protected-resource. +unauthenticated URL https://api.cloud.qdrant.io/.well-known/oauth-protected-resource. That document wins over hostname inference. After login, API calls send Authorization: Bearer . Management API keys @@ -23,11 +21,11 @@ keep working unchanged when --api-key / QDRANT_CLOUD_API_KEY is set. ### Examples ``` -# Discover the login URL for the current endpoint (no Auth0 app required) -qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443 +# Discover the login URL for the current endpoint +qcloud auth discover # Browser login (authorization code + PKCE on 127.0.0.1) -qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 --client-id +qcloud auth login --client-id # Device code (SSH / no browser) qcloud auth login --device --scope read-only diff --git a/docs/reference/qcloud_auth_discover.md b/docs/reference/qcloud_auth_discover.md index b18de29..f40994c 100644 --- a/docs/reference/qcloud_auth_discover.md +++ b/docs/reference/qcloud_auth_discover.md @@ -6,10 +6,11 @@ Show the OAuth login URL inferred from the API endpoint Print the OAuth API resource and authorization-server issuer for the current endpoint. -The gRPC endpoint is turned into https://api..qdrant.io. The CLI then -GETs /.well-known/oauth-protected-resource with no credentials. If the gateway -returns authorization_servers, that issuer is used; otherwise login. -is inferred. No Auth0 client id is required. +The gRPC endpoint (default grpc.cloud.qdrant.io:443) is turned into +https://api.cloud.qdrant.io. The CLI then GETs +/.well-known/oauth-protected-resource with no credentials. If the gateway +returns authorization_servers, that issuer is used; otherwise +login.cloud.qdrant.io is inferred. No client id is required. ``` qcloud auth discover [flags] @@ -18,11 +19,7 @@ qcloud auth discover [flags] ### Examples ``` -# Production (default endpoint) qcloud auth discover - -# Development -qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443 ``` ### Options diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md index 88e262a..58bc0e9 100644 --- a/docs/reference/qcloud_auth_login.md +++ b/docs/reference/qcloud_auth_login.md @@ -4,19 +4,18 @@ Log in with a browser or device code ### Synopsis -Log in through Auth0 and store a refreshable access token for the current endpoint. +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. The login URL comes from the gateway's unauthenticated protected-resource -metadata when OAuth is enabled, otherwise from rewriting grpc. to -login.. Override with --login-url only for debugging. +metadata when OAuth is enabled, otherwise from rewriting grpc.cloud.qdrant.io +to login.cloud.qdrant.io. Override with --login-url only for debugging. -The Auth0 native application ("Qdrant Cloud CLI") is not hard-coded yet. -Pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID after that app exists on -the tenant. Tokens are stored in credentials.yaml next to the config file -(mode 0600). +The native application client id is not hard-coded yet. +Pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID after that app exists. +Tokens are stored in credentials.yaml next to the config file (mode 0600). ``` qcloud auth login [flags] @@ -25,8 +24,8 @@ qcloud auth login [flags] ### Examples ``` -# Browser login against development -qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 --client-id +# Browser login (default production endpoint) +qcloud auth login --client-id # Read-only session qcloud auth login --scope read-only --client-id @@ -38,10 +37,10 @@ qcloud auth login --device --client-id ### Options ``` - --client-id string Auth0 native app client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID) + --client-id string OAuth native app client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID) --device Use the device authorization grant instead of a loopback browser flow -h, --help help for login - --login-url string Override the Auth0 issuer URL (defaults to discovery / endpoint inference) + --login-url string Override the login issuer URL (defaults to discovery / endpoint inference) --scope string OAuth scope: read-only or manage (default "manage") ``` diff --git a/docs/reference/qcloud_auth_status.md b/docs/reference/qcloud_auth_status.md index 5f3f9a3..1730b7a 100644 --- a/docs/reference/qcloud_auth_status.md +++ b/docs/reference/qcloud_auth_status.md @@ -17,7 +17,6 @@ qcloud auth status [flags] ``` qcloud auth status -qcloud auth status --endpoint grpc.staging-cloud.qdrant.io:443 ``` ### Options diff --git a/internal/cli/root.go b/internal/cli/root.go index bc02808..3474194 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,9 +33,12 @@ func NewRootCommand(s *state.State) *cobra.Command { Short: "Qdrant Cloud CLI", Long: `The command-line interface for Qdrant Cloud. -Get started: +Get started — choose one of: + qcloud auth login qcloud context set default --api-key --account-id + +Then: qcloud cluster list Documentation: https://github.com/qdrant/qcloud-cli`, diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index 2eb53d8..a1d38ac 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -13,24 +13,22 @@ func NewCommand(s *state.State) *cobra.Command { Short: "Log in to Qdrant Cloud with a browser", Long: `Log in to Qdrant Cloud with a browser (or a device code) instead of pasting an API key. -The Auth0 login host is not a separate flag. It is derived from the gRPC +The login host is not a separate flag. It is derived from the gRPC endpoint (flag --endpoint, env QDRANT_CLOUD_ENDPOINT, or the active context): - grpc.cloud.qdrant.io:443 → login.cloud.qdrant.io - grpc.staging-cloud.qdrant.io:443 → login.staging-cloud.qdrant.io - grpc.development-cloud.qdrant.io:443 → login.development-cloud.qdrant.io + grpc.cloud.qdrant.io:443 → login.cloud.qdrant.io When OAuth is enabled, the gateway also advertises the issuer at the -unauthenticated URL https://api..qdrant.io/.well-known/oauth-protected-resource. +unauthenticated URL https://api.cloud.qdrant.io/.well-known/oauth-protected-resource. That document wins over hostname inference. After login, API calls send Authorization: Bearer . Management API keys keep working unchanged when --api-key / QDRANT_CLOUD_API_KEY is set.`, - Example: `# Discover the login URL for the current endpoint (no Auth0 app required) -qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443 + Example: `# Discover the login URL for the current endpoint +qcloud auth discover # Browser login (authorization code + PKCE on 127.0.0.1) -qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 --client-id +qcloud auth login --client-id # Device code (SSH / no browser) qcloud auth login --device --scope read-only`, diff --git a/internal/cmd/auth/discover.go b/internal/cmd/auth/discover.go index cfe20a9..42f6a0c 100644 --- a/internal/cmd/auth/discover.go +++ b/internal/cmd/auth/discover.go @@ -14,15 +14,12 @@ func newDiscoverCommand(s *state.State) *cobra.Command { return base.Cmd{ Long: `Print the OAuth API resource and authorization-server issuer for the current endpoint. -The gRPC endpoint is turned into https://api..qdrant.io. The CLI then -GETs /.well-known/oauth-protected-resource with no credentials. If the gateway -returns authorization_servers, that issuer is used; otherwise login. -is inferred. No Auth0 client id is required.`, - Example: `# Production (default endpoint) -qcloud auth discover - -# Development -qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443`, +The gRPC endpoint (default grpc.cloud.qdrant.io:443) is turned into +https://api.cloud.qdrant.io. The CLI then GETs +/.well-known/oauth-protected-resource with no credentials. If the gateway +returns authorization_servers, that issuer is used; otherwise +login.cloud.qdrant.io is inferred. No client id is required.`, + Example: `qcloud auth discover`, BaseCobraCommand: func() *cobra.Command { return &cobra.Command{ Use: "discover", diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 0f2fd2d..2fa9cc2 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -13,21 +13,20 @@ import ( func newLoginCommand(s *state.State) *cobra.Command { return base.Cmd{ - Long: `Log in through Auth0 and store a refreshable access token for the current endpoint. + 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. The login URL comes from the gateway's unauthenticated protected-resource -metadata when OAuth is enabled, otherwise from rewriting grpc. to -login.. Override with --login-url only for debugging. +metadata when OAuth is enabled, otherwise from rewriting grpc.cloud.qdrant.io +to login.cloud.qdrant.io. Override with --login-url only for debugging. -The Auth0 native application ("Qdrant Cloud CLI") is not hard-coded yet. -Pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID after that app exists on -the tenant. Tokens are stored in credentials.yaml next to the config file -(mode 0600).`, - Example: `# Browser login against development -qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 --client-id +The native application client id is not hard-coded yet. +Pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID after that app exists. +Tokens are stored in credentials.yaml next to the config file (mode 0600).`, + Example: `# Browser login (default production endpoint) +qcloud auth login --client-id # Read-only session qcloud auth login --scope read-only --client-id @@ -42,8 +41,8 @@ qcloud auth login --device --client-id `, } cmd.Flags().String("scope", "manage", "OAuth scope: read-only or manage") cmd.Flags().Bool("device", false, "Use the device authorization grant instead of a loopback browser flow") - cmd.Flags().String("client-id", os.Getenv("QDRANT_CLOUD_OAUTH_CLIENT_ID"), "Auth0 native app client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID)") - cmd.Flags().String("login-url", "", "Override the Auth0 issuer URL (defaults to discovery / endpoint inference)") + cmd.Flags().String("client-id", os.Getenv("QDRANT_CLOUD_OAUTH_CLIENT_ID"), "OAuth native app client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID)") + cmd.Flags().String("login-url", "", "Override the login issuer URL (defaults to discovery / endpoint inference)") return cmd }, Run: func(s *state.State, cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index c7f2fc8..96e1133 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -17,8 +17,7 @@ func newStatusCommand(s *state.State) *cobra.Command { Prints the inferred login URL, token expiry, and scope. Does not print the access token.`, - Example: `qcloud auth status -qcloud auth status --endpoint grpc.staging-cloud.qdrant.io:443`, + Example: `qcloud auth status`, BaseCobraCommand: func() *cobra.Command { return &cobra.Command{ Use: "status", diff --git a/internal/oauth/login.go b/internal/oauth/login.go index 47edc4b..378db22 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -61,7 +61,7 @@ func (d Discoverer) Login(ctx context.Context, opts LoginOptions) (Token, Discov } if opts.ClientID == "" { - return Token{}, disc, fmt.Errorf("OAuth client id is required (--client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID) once the Qdrant Cloud CLI Auth0 app exists for this tenant") + return Token{}, disc, fmt.Errorf("OAuth client id is required (--client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID) once the Qdrant Cloud CLI OAuth app exists for this tenant") } as, err := d.fetchASMetadata(ctx, disc.Issuer) From 79ad74821de4ff9e2f5ffebb152c59de6910af20 Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:28:21 +0000 Subject: [PATCH 03/25] chore(deps): pin public-api to v0.203.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7d8f4bf..5aa09ef 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/qdrant/qdrant-cloud-public-api v0.201.0 + github.com/qdrant/qdrant-cloud-public-api v0.203.0 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index c13a6e7..d65a727 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/qdrant/qdrant-cloud-public-api v0.201.0 h1:3/n8ShFI5zW6FtOJgK0P3GftEJnCduBVTOJMRcn1tYs= -github.com/qdrant/qdrant-cloud-public-api v0.201.0/go.mod h1:kp/X69V2f0g/eQtzl5k4R/RE6tgcKcgBTq/zmQxTOB0= +github.com/qdrant/qdrant-cloud-public-api v0.203.0 h1:v8YoNDEQ/CQFbzYBVBqQT840dHxtNmsBjAAmX9YdS7k= +github.com/qdrant/qdrant-cloud-public-api v0.203.0/go.mod h1:kp/X69V2f0g/eQtzl5k4R/RE6tgcKcgBTq/zmQxTOB0= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= From e4e321702bc0249a069d6b45a67009e0c11bcb5e Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:04:53 +0200 Subject: [PATCH 04/25] [CP-768] fix(auth): device-code audience and callback iss (#224) * [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. * 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) --------- Co-authored-by: Robert-Stam Co-authored-by: Claude Opus 5.5 (1M context) --- 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 | 130 ++++++++++++++++++++++++++++ 5 files changed, 209 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..59294e2 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,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")) +} From edf82943d248e64f89ee4742bf4eb8fb73799a02 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Mon, 19 Oct 2026 17:28:54 +0200 Subject: [PATCH 05/25] [CP-768] fix(auth): listen on fixed loopback ports for the browser login Auth0 matches a loopback callback's port exactly, so a random port fails with Callback URL mismatch. Try 48271, 48272 and 48273 in order; each is registered on the CLI's Auth0 application. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/qcloud_auth_login.md | 4 ++-- internal/cmd/auth/login.go | 4 ++-- internal/oauth/login.go | 25 ++++++++++++++++++---- internal/oauth/login_test.go | 32 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md index a62bf8b..a16cbf0 100644 --- a/docs/reference/qcloud_auth_login.md +++ b/docs/reference/qcloud_auth_login.md @@ -7,8 +7,8 @@ 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). The browser callback checks state and, when Auth0 sends -it, iss. +127.0.0.1 (RFC 8252), on the first free port of 48271, 48272 and 48273. 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 diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index ab13b1d..8d61747 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -16,8 +16,8 @@ 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). The browser callback checks state and, when Auth0 sends -it, iss. +127.0.0.1 (RFC 8252), on the first free port of 48271, 48272 and 48273. 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 diff --git a/internal/oauth/login.go b/internal/oauth/login.go index a2553ad..e99001d 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -2,12 +2,14 @@ package oauth import ( "context" + "errors" "fmt" "io" "net" "net/http" "net/url" "slices" + "strconv" "strings" "time" ) @@ -17,6 +19,11 @@ const ( offlineScope = "offline_access" ) +// LoopbackPorts are the ports the browser login listens on, tried in order. Auth0 matches a +// loopback callback's port exactly (it does not apply the any-port rule of RFC 8252 §7.3), so +// each one is registered on the CLI's Auth0 application as http://127.0.0.1:/callback. +var LoopbackPorts = []int{48271, 48272, 48273} + // WaitCodeFunc waits for an authorization code at redirectURL (loopback). type WaitCodeFunc func(ctx context.Context, redirectURL, state string) (code string, err error) @@ -170,12 +177,22 @@ func listenLoopback(listen func() (net.Listener, error)) (net.Listener, error) { return listen() } - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, fmt.Errorf("listen on loopback: %w", err) + return listenFirstFree(LoopbackPorts) +} + +// listenFirstFree listens on the first of ports that is free on 127.0.0.1. +func listenFirstFree(ports []int) (net.Listener, error) { + var errs []error + for _, port := range ports { + ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err == nil { + return ln, nil + } + + errs = append(errs, err) } - return ln, nil + return nil, fmt.Errorf("listen on loopback: none of ports %v is free (or use --device): %w", ports, errors.Join(errs...)) } func loopbackWaiter(ln net.Listener, out io.Writer, issuer string) WaitCodeFunc { diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index 59294e2..c2eb514 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "net" "net/http" "net/http/httptest" "net/url" @@ -210,3 +211,34 @@ func TestLogin_DeviceUsesAudience(t *testing.T) { assert.Empty(t, tokenForm.Get("resource")) assert.Empty(t, tokenForm.Get("audience")) } + +func TestListenFirstFree_SkipsBusyPort(t *testing.T) { + t.Parallel() + + busy, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer busy.Close() + + free, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + freePort := free.Addr().(*net.TCPAddr).Port + require.NoError(t, free.Close()) + + ln, err := listenFirstFree([]int{busy.Addr().(*net.TCPAddr).Port, freePort}) + require.NoError(t, err) + defer ln.Close() + + assert.Equal(t, freePort, ln.Addr().(*net.TCPAddr).Port) +} + +func TestListenFirstFree_AllBusy(t *testing.T) { + t.Parallel() + + busy, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer busy.Close() + + _, err = listenFirstFree([]int{busy.Addr().(*net.TCPAddr).Port}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--device") +} From a0ac15a59a7785ff723ce6834844c70737ccec9a Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Tue, 20 Oct 2026 11:27:46 +0200 Subject: [PATCH 06/25] [CP-768] feat(auth): built-in Qdrant Cloud CLI client id per login URL Each Auth0 tenant generates its own client id for the native app, and a native app's id is public, so ship them keyed by issuer: users no longer pass --client-id. Development is set; staging and production are TBD until their apps exist, and ask for --client-id meanwhile. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/qcloud_auth.md | 2 +- docs/reference/qcloud_auth_login.md | 12 ++++++------ internal/cmd/auth/auth.go | 2 +- internal/cmd/auth/login.go | 12 ++++++------ internal/oauth/clientid.go | 23 +++++++++++++++++++++++ internal/oauth/clientid_test.go | 24 ++++++++++++++++++++++++ internal/oauth/login.go | 7 ++++++- 7 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 internal/oauth/clientid.go create mode 100644 internal/oauth/clientid_test.go diff --git a/docs/reference/qcloud_auth.md b/docs/reference/qcloud_auth.md index f71a515..73d0893 100644 --- a/docs/reference/qcloud_auth.md +++ b/docs/reference/qcloud_auth.md @@ -25,7 +25,7 @@ keep working unchanged when --api-key / QDRANT_CLOUD_API_KEY is set. qcloud auth discover # Browser login (authorization code + PKCE on 127.0.0.1) -qcloud auth login --client-id +qcloud auth login # Device code (SSH / no browser) qcloud auth login --device --scope read-only diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md index a16cbf0..91cba9c 100644 --- a/docs/reference/qcloud_auth_login.md +++ b/docs/reference/qcloud_auth_login.md @@ -18,8 +18,8 @@ The login URL comes from the gateway's unauthenticated protected-resource metadata when OAuth is enabled, otherwise from rewriting grpc.cloud.qdrant.io to login.cloud.qdrant.io. Override with --login-url only for debugging. -The native application client id is not hard-coded yet. -Pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID after that app exists. +The Qdrant Cloud CLI client id is built in per login URL. --client-id or +QDRANT_CLOUD_OAUTH_CLIENT_ID override it, for a tenant without a built-in id. Tokens are stored in credentials.yaml next to the config file (mode 0600). ``` @@ -30,19 +30,19 @@ qcloud auth login [flags] ``` # Browser login (default production endpoint) -qcloud auth login --client-id +qcloud auth login # Read-only session -qcloud auth login --scope read-only --client-id +qcloud auth login --scope read-only # Device code -qcloud auth login --device --client-id +qcloud auth login --device ``` ### Options ``` - --client-id string OAuth native app client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID) + --client-id string Override the built-in OAuth client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID) --device Use the device authorization grant instead of a loopback browser flow -h, --help help for login --login-url string Override the login issuer URL (defaults to discovery / endpoint inference) diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index a1d38ac..ac5cdd8 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -28,7 +28,7 @@ keep working unchanged when --api-key / QDRANT_CLOUD_API_KEY is set.`, qcloud auth discover # Browser login (authorization code + PKCE on 127.0.0.1) -qcloud auth login --client-id +qcloud auth login # Device code (SSH / no browser) qcloud auth login --device --scope read-only`, diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 8d61747..55d6fd2 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -27,17 +27,17 @@ The login URL comes from the gateway's unauthenticated protected-resource metadata when OAuth is enabled, otherwise from rewriting grpc.cloud.qdrant.io to login.cloud.qdrant.io. Override with --login-url only for debugging. -The native application client id is not hard-coded yet. -Pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID after that app exists. +The Qdrant Cloud CLI client id is built in per login URL. --client-id or +QDRANT_CLOUD_OAUTH_CLIENT_ID override it, for a tenant without a built-in id. Tokens are stored in credentials.yaml next to the config file (mode 0600).`, Example: `# Browser login (default production endpoint) -qcloud auth login --client-id +qcloud auth login # Read-only session -qcloud auth login --scope read-only --client-id +qcloud auth login --scope read-only # Device code -qcloud auth login --device --client-id `, +qcloud auth login --device`, BaseCobraCommand: func() *cobra.Command { cmd := &cobra.Command{ Use: "login", @@ -46,7 +46,7 @@ qcloud auth login --device --client-id `, } cmd.Flags().String("scope", "manage", "OAuth scope: read-only or manage") cmd.Flags().Bool("device", false, "Use the device authorization grant instead of a loopback browser flow") - cmd.Flags().String("client-id", os.Getenv("QDRANT_CLOUD_OAUTH_CLIENT_ID"), "OAuth native app client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID)") + cmd.Flags().String("client-id", os.Getenv("QDRANT_CLOUD_OAUTH_CLIENT_ID"), "Override the built-in OAuth client id (env: QDRANT_CLOUD_OAUTH_CLIENT_ID)") cmd.Flags().String("login-url", "", "Override the login issuer URL (defaults to discovery / endpoint inference)") return cmd }, diff --git a/internal/oauth/clientid.go b/internal/oauth/clientid.go new file mode 100644 index 0000000..e00bcc4 --- /dev/null +++ b/internal/oauth/clientid.go @@ -0,0 +1,23 @@ +package oauth + +// clientIDTBD marks a tenant whose "Qdrant Cloud CLI" Auth0 application does not exist yet. +const clientIDTBD = "TBD" + +// cliClientIDs are the client IDs of the "Qdrant Cloud CLI" native application, one per Auth0 +// tenant, keyed by issuer. Each tenant generates its own, and they are public (a native app has +// no secret), so they ship with the CLI and users never pass one. +var cliClientIDs = map[string]string{ + "https://login.cloud.qdrant.io/": clientIDTBD, + "https://login.staging-cloud.qdrant.io/": clientIDTBD, + "https://login.development-cloud.qdrant.io/": "CjeXCZKv1plzIF9h3vZBGAhM6MLE68fI", +} + +// DefaultClientID returns the built-in client ID for issuer, if that tenant has one. +func DefaultClientID(issuer string) (string, bool) { + id, ok := cliClientIDs[canonicalizeIssuer(issuer)] + if !ok || id == clientIDTBD { + return "", false + } + + return id, true +} diff --git a/internal/oauth/clientid_test.go b/internal/oauth/clientid_test.go new file mode 100644 index 0000000..257ed25 --- /dev/null +++ b/internal/oauth/clientid_test.go @@ -0,0 +1,24 @@ +package oauth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultClientID(t *testing.T) { + t.Parallel() + + id, ok := DefaultClientID("https://login.development-cloud.qdrant.io") + assert.True(t, ok) + assert.Equal(t, "CjeXCZKv1plzIF9h3vZBGAhM6MLE68fI", id) + + for _, issuer := range []string{ + "https://login.cloud.qdrant.io/", // TBD + "https://login.staging-cloud.qdrant.io/", // TBD + "https://login.example.com/", // unknown + } { + _, ok := DefaultClientID(issuer) + assert.False(t, ok, issuer) + } +} diff --git a/internal/oauth/login.go b/internal/oauth/login.go index e99001d..f334f9c 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -68,7 +68,12 @@ func (d Discoverer) Login(ctx context.Context, opts LoginOptions) (Token, Discov } if opts.ClientID == "" { - return Token{}, disc, fmt.Errorf("OAuth client id is required (--client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID) once the Qdrant Cloud CLI OAuth app exists for this tenant") + id, ok := DefaultClientID(disc.Issuer) + if !ok { + return Token{}, disc, fmt.Errorf("no built-in OAuth client id for %s yet: pass --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID", disc.Issuer) + } + + opts.ClientID = id } as, err := d.fetchASMetadata(ctx, disc.Issuer) From 1cade254ae019ca0b92a6e8b3afef3ce0af4c36d Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Tue, 20 Oct 2026 17:33:44 +0200 Subject: [PATCH 07/25] [CP-768] feat(auth): HTML page for the browser login callback A self-contained page (inline Qdrant mark and terminal icon, no fonts, scripts or other requests, light and dark) for success and failure, instead of plain text. The failure detail is HTML-escaped; strict CSP. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/oauth/callback.html | 127 ++++++++++++++++++++++++++++++++ internal/oauth/callback_page.go | 30 ++++++++ internal/oauth/login.go | 5 +- internal/oauth/login_test.go | 38 ++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 internal/oauth/callback.html create mode 100644 internal/oauth/callback_page.go diff --git a/internal/oauth/callback.html b/internal/oauth/callback.html new file mode 100644 index 0000000..54df867 --- /dev/null +++ b/internal/oauth/callback.html @@ -0,0 +1,127 @@ + + + + + +{{if .OK}}Logged in{{else}}Login failed{{end}} · Qdrant Cloud CLI + + + +
+ +{{if .OK}} +

Logged in to Qdrant Cloud

+

+ + You can close this tab and return to the terminal. +

+{{else}} +

Login failed

+

+ + Run qcloud auth login again. +

+
{{.Detail}}
+{{end}} +
+ + diff --git a/internal/oauth/callback_page.go b/internal/oauth/callback_page.go new file mode 100644 index 0000000..66d91fa --- /dev/null +++ b/internal/oauth/callback_page.go @@ -0,0 +1,30 @@ +package oauth + +import ( + _ "embed" + "html/template" + "net/http" +) + +//go:embed callback.html +var callbackPageSource string + +// callbackPage is what the browser shows on the loopback redirect. It is self-contained: the logos +// are inline SVG and there is no font, script or other request, so it renders offline too. +var callbackPage = template.Must(template.New("callback").Parse(callbackPageSource)) + +// writeCallbackPage renders the success page, or the failure page with detail (HTML-escaped). +func writeCallbackPage(w http.ResponseWriter, status int, detail string) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-store") + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + w.WriteHeader(status) + + _ = callbackPage.Execute(w, struct { + OK bool + Detail string + }{OK: status == http.StatusOK, Detail: detail}) +} diff --git a/internal/oauth/login.go b/internal/oauth/login.go index f334f9c..1c61c9c 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -209,12 +209,13 @@ func loopbackWaiter(ln net.Listener, out io.Writer, issuer string) WaitCodeFunc mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { code, err := authorizationCallback(r.URL.Query(), wantState, issuer) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeCallbackPage(w, http.StatusBadRequest, err.Error()) errCh <- err + return } - fmt.Fprint(w, "You can close this window and return to the CLI.") + writeCallbackPage(w, http.StatusOK, "") codeCh <- code }) diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index c2eb514..565864e 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -242,3 +242,41 @@ func TestListenFirstFree_AllBusy(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "--device") } + +func TestLoopbackWaiter_Pages(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, query, want string + status int + }{ + {"success", "code=abc&state=s", "Logged in to Qdrant Cloud", http.StatusOK}, + {"denied", "error=access_denied&error_description=" + url.QueryEscape("not connected") + "&state=s", "<b>not connected</b>", http.StatusBadRequest}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + wait := loopbackWaiter(ln, nil, "https://login.cloud.qdrant.io/") + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = wait(context.Background(), "", "s") + }() + + resp, err := http.Get("http://" + ln.Addr().String() + "/callback?" + tc.query) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, resp.Body.Close()) + require.NoError(t, err) + <-done + + assert.Equal(t, tc.status, resp.StatusCode) + assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.Contains(t, string(body), tc.want) + assert.NotContains(t, string(body), "not connected") + }) + } +} From 98eadfbf2dc48ce58f84fd5ad2e2da29971ee08f Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Tue, 20 Oct 2026 17:37:01 +0200 Subject: [PATCH 08/25] [CP-768] feat(auth): callback page in the style of the Qdrant Cloud login Same grid background, wordmark above a white card, title and tiles as the Auth0 Universal Login pages. Still self-contained: the grid image and logos are inline, the system font replaces the web font. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/oauth/callback.html | 153 ++++++++++++++++------------------- internal/oauth/login_test.go | 2 +- 2 files changed, 70 insertions(+), 85 deletions(-) diff --git a/internal/oauth/callback.html b/internal/oauth/callback.html index 54df867..6e7bcbc 100644 --- a/internal/oauth/callback.html +++ b/internal/oauth/callback.html @@ -5,77 +5,70 @@ {{if .OK}}Logged in{{else}}Login failed{{end}} · Qdrant Cloud CLI -
-
+ + diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index 565864e..06307f4 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -250,7 +250,7 @@ func TestLoopbackWaiter_Pages(t *testing.T) { name, query, want string status int }{ - {"success", "code=abc&state=s", "Logged in to Qdrant Cloud", http.StatusOK}, + {"success", "code=abc&state=s", "Login Complete", http.StatusOK}, {"denied", "error=access_denied&error_description=" + url.QueryEscape("not connected") + "&state=s", "<b>not connected</b>", http.StatusBadRequest}, } { t.Run(tc.name, func(t *testing.T) { From 9a8b07699ace2117c6d48c3a5bb1883df37fd3ca Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Wed, 21 Oct 2026 17:09:15 +0200 Subject: [PATCH 09/25] [CP-768] fix(auth): discover says why the protected-resource metadata was not used On development, discover fell back to endpoint inference while curl got the metadata; the reason was swallowed. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/discover.go | 4 ++++ internal/oauth/discover.go | 20 ++++++++------------ internal/oauth/discover_test.go | 1 + 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/internal/cmd/auth/discover.go b/internal/cmd/auth/discover.go index 42f6a0c..8b95374 100644 --- a/internal/cmd/auth/discover.go +++ b/internal/cmd/auth/discover.go @@ -38,6 +38,10 @@ login.cloud.qdrant.io is inferred. No client id is required.`, fmt.Fprintf(out, "API resource: %s\n", disc.Resource) fmt.Fprintf(out, "Login URL: %s\n", disc.Issuer) fmt.Fprintf(out, "Source: %s\n", disc.Source) + if disc.MetadataError != "" { + fmt.Fprintf(out, "Metadata: not used (%s)\n", disc.MetadataError) + } + if len(disc.Scopes) > 0 { fmt.Fprintf(out, "Scopes: %s\n", strings.Join(disc.Scopes, ", ")) } diff --git a/internal/oauth/discover.go b/internal/oauth/discover.go index 6224787..9fe6d62 100644 --- a/internal/oauth/discover.go +++ b/internal/oauth/discover.go @@ -22,6 +22,9 @@ type Discovery struct { Issuer string Scopes []string Source string + // MetadataError is why the protected-resource metadata could not be used, when Source is + // endpoint inference. The inferred values work too; this explains why they were inferred. + MetadataError string } // Discoverer resolves the authorization server for a gRPC endpoint. @@ -53,10 +56,12 @@ func (d Discoverer) Discover(ctx context.Context, endpoint string) (Discovery, e Source: sourceEndpointInference, } - prm, ok := d.tryPRM(ctx, hosts.ProtectedResourceMetadataURL()) - if !ok { + prm, err := d.fetchPRM(ctx, hosts.ProtectedResourceMetadataURL()) + if err != nil { // OAuth may not be enabled yet; hostname inference still works. - return out, nil + out.MetadataError = err.Error() + + return out, nil //nolint:nilerr // the inferred values are the fallback; the reason is in MetadataError } if len(prm.AuthorizationServers) > 0 && prm.AuthorizationServers[0] != "" { @@ -81,15 +86,6 @@ type prmDocument struct { ScopesSupported []string `json:"scopes_supported"` } -func (d Discoverer) tryPRM(ctx context.Context, url string) (prmDocument, bool) { - doc, err := d.fetchPRM(ctx, url) - if err != nil { - return prmDocument{}, false - } - - return doc, true -} - func (d Discoverer) fetchPRM(ctx context.Context, url string) (prmDocument, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/internal/oauth/discover_test.go b/internal/oauth/discover_test.go index 7292eb5..b5c207f 100644 --- a/internal/oauth/discover_test.go +++ b/internal/oauth/discover_test.go @@ -23,6 +23,7 @@ func TestDiscover_FallsBackToInferredIssuer(t *testing.T) { assert.Equal(t, "https://login.development-cloud.qdrant.io/", disc.Issuer) assert.Equal(t, "https://api.development-cloud.qdrant.io", disc.Resource) assert.Equal(t, sourceEndpointInference, disc.Source) + assert.Contains(t, disc.MetadataError, assert.AnError.Error()) } func TestDiscover_UsesProtectedResourceMetadata(t *testing.T) { From a737041f10167bd11e5871f7e5a7802b53ea3c23 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Wed, 21 Oct 2026 17:34:26 +0200 Subject: [PATCH 10/25] [CP-768] fix(auth): request openid and email, so the token carries email_verified iam-service's ExchangeToken requires email_verified in the Auth0 access token. The custom-token-claims Action sets it, but Auth0 silently drops it: email_verified is a reserved OpenID Connect claim name. With the email scope Auth0 adds it itself, as for the Cloud console. Found on development: invalid_grant: email not verified in JWT payload. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/oauth/login.go | 14 ++++++++++---- internal/oauth/login_test.go | 7 +++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/oauth/login.go b/internal/oauth/login.go index 1c61c9c..225441c 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -19,6 +19,12 @@ const ( offlineScope = "offline_access" ) +// requiredScopes are requested on every login next to read-only or manage. offline_access gives a +// refresh token. openid and email make Auth0 put email_verified in the access token, which +// iam-service requires: Auth0 drops email_verified when an Action sets it, as it is a reserved +// OpenID Connect claim name, so only the email scope brings it in. +var requiredScopes = []string{"openid", "email", offlineScope} + // LoopbackPorts are the ports the browser login listens on, tried in order. Auth0 matches a // loopback callback's port exactly (it does not apply the any-port rule of RFC 8252 §7.3), so // each one is registered on the CLI's Auth0 application as http://127.0.0.1:/callback. @@ -94,10 +100,10 @@ func (d Discoverer) Login(ctx context.Context, opts LoginOptions) (Token, Discov func joinScopes(scope string) string { parts := strings.Fields(scope) - hasOffline := slices.Contains(parts, offlineScope) - - if !hasOffline { - parts = append(parts, offlineScope) + for _, required := range requiredScopes { + if !slices.Contains(parts, required) { + parts = append(parts, required) + } } return strings.Join(parts, " ") diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index 06307f4..0370b0f 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -280,3 +280,10 @@ func TestLoopbackWaiter_Pages(t *testing.T) { }) } } + +func TestJoinScopes(t *testing.T) { + t.Parallel() + + assert.Equal(t, "manage openid email offline_access", joinScopes("manage")) + assert.Equal(t, "read-only openid email offline_access", joinScopes("read-only openid")) +} From 7276eea31799c792ad85c796591d206cc75bc3db Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 10:52:45 +0200 Subject: [PATCH 11/25] [CP-768] feat(auth): use the only granted account without --account-id After a login, qcloud lists the accounts once. When the login was granted exactly one, it is stored with the session and used when no flag, environment variable or context sets an account; auth status shows it. With an API key nothing is looked up, and a failed lookup never fails the login. Needs cluster-api's ListAccounts to return only the grant's accounts to OAuth callers (qdrant-cloud-cluster-api#5223). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/qcloud_auth_login.md | 4 ++ internal/cmd/auth/auth_test.go | 98 +++++++++++++++++++++-------- internal/cmd/auth/login.go | 54 +++++++++++++++- internal/cmd/auth/status.go | 4 ++ internal/oauth/store.go | 3 + internal/state/state.go | 17 +++-- 6 files changed, 149 insertions(+), 31 deletions(-) diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md index 91cba9c..86f3ed9 100644 --- a/docs/reference/qcloud_auth_login.md +++ b/docs/reference/qcloud_auth_login.md @@ -22,6 +22,10 @@ The Qdrant Cloud CLI client id is built in per login URL. --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID override it, for a tenant without a built-in id. Tokens are stored in credentials.yaml next to the config file (mode 0600). +When the login is granted exactly one account, that account is stored with the +session and used by commands when no --account-id, QDRANT_CLOUD_ACCOUNT_ID or +context sets one ("qcloud auth status" shows it). + ``` qcloud auth login [flags] ``` diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index 51b73dd..4e62157 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -11,6 +11,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" "github.com/qdrant/qcloud-cli/internal/state" "github.com/qdrant/qcloud-cli/internal/testutil" @@ -61,10 +66,10 @@ func TestAuthDiscover_UsesGatewayMetadata(t *testing.T) { assert.Contains(t, stdout, "protected-resource metadata") } -func TestAuthLogin_StoresToken(t *testing.T) { - env := newEnv(t) - home := t.TempDir() - t.Setenv("HOME", home) +// prepareLogin points env at a fake authorization server that completes a browser login. +func prepareLogin(t *testing.T, env *testutil.TestEnv) { + t.Helper() + t.Setenv("HOME", t.TempDir()) mux := http.NewServeMux() mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, _ *http.Request) { @@ -103,42 +108,85 @@ func TestAuthLogin_StoresToken(t *testing.T) { env.State.SetWaitAuthCode(func(context.Context, string, string) (string, error) { return "test-code", nil }, "http://127.0.0.1:1/callback") +} - stdout, _, err := testutil.Exec(t, env, - "auth", "login", - "--endpoint", "grpc.development-cloud.qdrant.io:443", - "--client-id", "cli-app", - ) +const devEndpoint = "grpc.development-cloud.qdrant.io:443" + +func TestAuthLogin_StoresToken(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) assert.Contains(t, stdout, "Logged in") - tokenOut, _, err := testutil.Exec(t, env, - "auth", "token", - "--endpoint", "grpc.development-cloud.qdrant.io:443", - ) + tokenOut, _, err := testutil.Exec(t, env, "auth", "token", "--endpoint", devEndpoint) require.NoError(t, err) assert.Equal(t, "access-from-login\n", tokenOut) - statusOut, _, err := testutil.Exec(t, env, - "auth", "status", - "--endpoint", "grpc.development-cloud.qdrant.io:443", - ) + statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) require.NoError(t, err) assert.Contains(t, statusOut, "logged in") - _, _, err = testutil.Exec(t, env, - "auth", "logout", - "--endpoint", "grpc.development-cloud.qdrant.io:443", - ) + _, _, err = testutil.Exec(t, env, "auth", "logout", "--endpoint", devEndpoint) require.NoError(t, err) - _, _, err = testutil.Exec(t, env, - "auth", "token", - "--endpoint", "grpc.development-cloud.qdrant.io:443", - ) + _, _, err = testutil.Exec(t, env, "auth", "token", "--endpoint", devEndpoint) require.Error(t, err) } +func TestAuthLogin_RemembersTheOnlyGrantedAccount(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{ + Items: []*accountv1.Account{{Id: "acc-only", Name: "Only Account"}}, + }, nil) + env.Server.ListClustersCalls.Returns(&clusterv1.ListClustersResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") + require.NoError(t, err) + assert.Contains(t, stdout, "Account: Only Account (acc-only)") + + statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) + require.NoError(t, err) + assert.Contains(t, statusOut, "Account: acc-only") + + // No --account-id: the command uses the account the login was granted. + _, _, err = testutil.Exec(t, env, "cluster", "list", "--endpoint", devEndpoint) + require.NoError(t, err) + req, ok := env.Server.ListClustersCalls.Last() + require.True(t, ok) + assert.Equal(t, "acc-only", req.GetAccountId()) +} + +func TestAuthLogin_SeveralGrantedAccountsAreNotChosen(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{ + Items: []*accountv1.Account{{Id: "a1", Name: "One"}, {Id: "a2", Name: "Two"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") + require.NoError(t, err) + assert.Contains(t, stdout, "Accounts: 2 available") + + _, _, err = testutil.Exec(t, env, "cluster", "list", "--endpoint", devEndpoint) + require.Error(t, err) + assert.Contains(t, err.Error(), "no account ID configured") +} + +func TestAuthLogin_AccountLookupFailureDoesNotFailLogin(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + env.AccountServer.ListAccountsCalls.Returns(nil, status.Error(codes.Unavailable, "down")) + + stdout, stderr, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") + require.NoError(t, err) + assert.Contains(t, stdout, "Logged in") + assert.Contains(t, stderr, "Could not list your accounts") +} + func TestAuthLogin_MissingClientID(t *testing.T) { env := newEnv(t) t.Setenv("QDRANT_CLOUD_OAUTH_CLIENT_ID", "") diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 55d6fd2..e6d6edd 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -1,11 +1,15 @@ package auth import ( + "context" "fmt" "os" + "time" "github.com/spf13/cobra" + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + "github.com/qdrant/qcloud-cli/internal/cmd/base" "github.com/qdrant/qcloud-cli/internal/oauth" "github.com/qdrant/qcloud-cli/internal/state" @@ -29,7 +33,11 @@ to login.cloud.qdrant.io. Override with --login-url only for debugging. The Qdrant Cloud CLI client id is built in per login URL. --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID override it, for a tenant without a built-in id. -Tokens are stored in credentials.yaml next to the config file (mode 0600).`, +Tokens are stored in credentials.yaml next to the config file (mode 0600). + +When the login is granted exactly one account, that account is stored with the +session and used by commands when no --account-id, QDRANT_CLOUD_ACCOUNT_ID or +context sets one ("qcloud auth status" shows it).`, Example: `# Browser login (default production endpoint) qcloud auth login @@ -79,7 +87,51 @@ qcloud auth login --device`, } fmt.Fprintf(cmd.OutOrStdout(), "Logged in.\nLogin URL: %s (%s)\nResource: %s\nScope: %s\n", disc.Issuer, disc.Source, disc.Resource, tok.Scope) + rememberGrantedAccount(cmd, s, path, tok) + return nil }, }.CobraCommand(s) } + +// accountLookupTimeout bounds the account lookup after login; the login itself has already succeeded. +const accountLookupTimeout = 15 * time.Second + +// rememberGrantedAccount stores the account the login was granted when it was granted exactly one, so +// commands need no --account-id. It never fails the login: without it, commands ask for an account as +// before. An API key takes precedence over the OAuth session, so then there is nothing to look up. +func rememberGrantedAccount(cmd *cobra.Command, s *state.State, path string, tok oauth.Token) { + if s.Config.APIKey() != "" { + return + } + + stderr := cmd.ErrOrStderr() + ctx, cancel := context.WithTimeout(cmd.Context(), accountLookupTimeout) + defer cancel() + + client, err := s.Client(ctx) + if err != nil { + fmt.Fprintf(stderr, "Could not list your accounts (%v); pass --account-id to commands.\n", err) + return + } + + resp, err := client.Account().ListAccounts(ctx, &accountv1.ListAccountsRequest{}) + if err != nil { + fmt.Fprintf(stderr, "Could not list your accounts (%v); pass --account-id to commands.\n", err) + return + } + + accounts := resp.GetItems() + if len(accounts) != 1 { + fmt.Fprintf(cmd.OutOrStdout(), "Accounts: %d available; pass --account-id, or set a context with \"qcloud context set\"\n", len(accounts)) + return + } + + tok.AccountID = accounts[0].GetId() + if err := oauth.SaveToken(path, tok); err != nil { + fmt.Fprintf(stderr, "Could not store the account: %v\n", err) + return + } + + fmt.Fprintf(cmd.OutOrStdout(), "Account: %s (%s), used when no --account-id is given\n", accounts[0].GetName(), tok.AccountID) +} diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index 96e1133..1564f5f 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -53,6 +53,10 @@ access token.`, fmt.Fprintf(out, "Status: %s\n", status) fmt.Fprintf(out, "Scope: %s\n", tok.Scope) + if tok.AccountID != "" { + fmt.Fprintf(out, "Account: %s (default for commands)\n", tok.AccountID) + } + if !tok.Expiry.IsZero() { fmt.Fprintf(out, "Expiry: %s\n", tok.Expiry.UTC().Format(time.RFC3339)) } diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 2790279..873feef 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -22,6 +22,9 @@ type Token struct { Issuer string `yaml:"issuer,omitempty"` ClientID string `yaml:"client_id,omitempty"` Resource string `yaml:"resource,omitempty"` + // AccountID is the account the login was granted, when it was granted exactly one. Commands use it + // when no account is set by flag, environment or context. + AccountID string `yaml:"account_id,omitempty"` } // Expired reports whether the access token should be refreshed. diff --git a/internal/state/state.go b/internal/state/state.go index d3a8089..2796b56 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -205,12 +205,19 @@ func (s *State) SetUpdater(u Updater) { s.updater = u } -// AccountID returns the configured account ID or an error. +// AccountID returns the configured account ID or an error. Without one from a flag, the environment +// or a context, it falls back to the account an OAuth login was granted, when it was granted exactly one. func (s *State) AccountID() (string, error) { - id := s.Config.AccountID() - if id == "" { - return "", errNoAccountID + if id := s.Config.AccountID(); id != "" { + return id, nil } - return id, nil + if s.Config.APIKey() == "" { + path := oauth.CredentialsPath(s.Config.ConfigFilePath()) + if tok, ok, err := oauth.LoadToken(path, s.Config.Endpoint()); err == nil && ok && tok.AccountID != "" { + return tok.AccountID, nil + } + } + + return "", errNoAccountID } From d8e924188b5bca8de4c0638f7d470800a69bdc13 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 11:07:22 +0200 Subject: [PATCH 12/25] [CP-768] feat(auth): keep auth login output to a minimum The login printed its discovery details (login URL, source, resource, scope), which auth discover and auth status already show, and a waiting line. Now: the browser line with its fallback URL, then one result line, e.g. "Logged in as ada@example.com, account Production." The email is read from the token for display only. The device flow prints only the URL and the code. Warnings stay on stderr. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/auth_test.go | 4 ++-- internal/cmd/auth/login.go | 32 +++++++++++++++++++------------- internal/oauth/claims.go | 30 ++++++++++++++++++++++++++++++ internal/oauth/claims_test.go | 17 +++++++++++++++++ internal/oauth/device.go | 4 ++-- internal/oauth/login.go | 10 +++------- internal/oauth/login_test.go | 2 +- 7 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 internal/oauth/claims.go create mode 100644 internal/oauth/claims_test.go diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index 4e62157..9458fbf 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -146,7 +146,7 @@ func TestAuthLogin_RemembersTheOnlyGrantedAccount(t *testing.T) { stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) - assert.Contains(t, stdout, "Account: Only Account (acc-only)") + assert.Contains(t, stdout, "Logged in, account Only Account.") statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) require.NoError(t, err) @@ -169,7 +169,7 @@ func TestAuthLogin_SeveralGrantedAccountsAreNotChosen(t *testing.T) { stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) - assert.Contains(t, stdout, "Accounts: 2 available") + assert.Contains(t, stdout, "Logged in; 2 accounts available, pass --account-id.") _, _, err = testutil.Exec(t, env, "cluster", "list", "--endpoint", devEndpoint) require.Error(t, err) diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index e6d6edd..94d09b6 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -76,7 +76,7 @@ qcloud auth login --device`, Output: cmd.ErrOrStderr(), } - tok, disc, err := s.OAuthDiscoverer().Login(cmd.Context(), opts) + tok, _, err := s.OAuthDiscoverer().Login(cmd.Context(), opts) if err != nil { return err } @@ -86,8 +86,14 @@ qcloud auth login --device`, return err } - fmt.Fprintf(cmd.OutOrStdout(), "Logged in.\nLogin URL: %s (%s)\nResource: %s\nScope: %s\n", disc.Issuer, disc.Source, disc.Resource, tok.Scope) - rememberGrantedAccount(cmd, s, path, tok) + account := rememberGrantedAccount(cmd, s, path, tok) + + who := "Logged in" + if email := oauth.TokenEmail(tok.AccessToken); email != "" { + who += " as " + email + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s%s.\n", who, account) return nil }, @@ -98,11 +104,12 @@ qcloud auth login --device`, const accountLookupTimeout = 15 * time.Second // rememberGrantedAccount stores the account the login was granted when it was granted exactly one, so -// commands need no --account-id. It never fails the login: without it, commands ask for an account as -// before. An API key takes precedence over the OAuth session, so then there is nothing to look up. -func rememberGrantedAccount(cmd *cobra.Command, s *state.State, path string, tok oauth.Token) { +// commands need no --account-id, and returns the text the login line ends with. It never fails the +// login: without it, commands ask for an account as before. An API key takes precedence over the OAuth +// session, so then there is nothing to look up. +func rememberGrantedAccount(cmd *cobra.Command, s *state.State, path string, tok oauth.Token) string { if s.Config.APIKey() != "" { - return + return "" } stderr := cmd.ErrOrStderr() @@ -112,26 +119,25 @@ func rememberGrantedAccount(cmd *cobra.Command, s *state.State, path string, tok client, err := s.Client(ctx) if err != nil { fmt.Fprintf(stderr, "Could not list your accounts (%v); pass --account-id to commands.\n", err) - return + return "" } resp, err := client.Account().ListAccounts(ctx, &accountv1.ListAccountsRequest{}) if err != nil { fmt.Fprintf(stderr, "Could not list your accounts (%v); pass --account-id to commands.\n", err) - return + return "" } accounts := resp.GetItems() if len(accounts) != 1 { - fmt.Fprintf(cmd.OutOrStdout(), "Accounts: %d available; pass --account-id, or set a context with \"qcloud context set\"\n", len(accounts)) - return + return fmt.Sprintf("; %d accounts available, pass --account-id", len(accounts)) } tok.AccountID = accounts[0].GetId() if err := oauth.SaveToken(path, tok); err != nil { fmt.Fprintf(stderr, "Could not store the account: %v\n", err) - return + return "" } - fmt.Fprintf(cmd.OutOrStdout(), "Account: %s (%s), used when no --account-id is given\n", accounts[0].GetName(), tok.AccountID) + return ", account " + accounts[0].GetName() } diff --git a/internal/oauth/claims.go b/internal/oauth/claims.go new file mode 100644 index 0000000..1b41a76 --- /dev/null +++ b/internal/oauth/claims.go @@ -0,0 +1,30 @@ +package oauth + +import ( + "encoding/base64" + "encoding/json" + "strings" +) + +// TokenEmail returns the user_email claim of an access token, for display only: the signature is not +// checked here (the API does that on every call). Empty when the token carries no email. +func TokenEmail(accessToken string) string { + parts := strings.Split(accessToken, ".") + if len(parts) != 3 { + return "" + } + + payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) + if err != nil { + return "" + } + + var claims struct { + UserEmail string `json:"user_email"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + + return claims.UserEmail +} diff --git a/internal/oauth/claims_test.go b/internal/oauth/claims_test.go new file mode 100644 index 0000000..01f79bd --- /dev/null +++ b/internal/oauth/claims_test.go @@ -0,0 +1,17 @@ +package oauth + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTokenEmail(t *testing.T) { + t.Parallel() + + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"user_email":"ada@example.com"}`)) + assert.Equal(t, "ada@example.com", TokenEmail("h."+payload+".s")) + assert.Empty(t, TokenEmail("opaque-token")) + assert.Empty(t, TokenEmail("h."+base64.RawURLEncoding.EncodeToString([]byte(`{}`))+".s")) +} diff --git a/internal/oauth/device.go b/internal/oauth/device.go index 47ec83f..7889348 100644 --- a/internal/oauth/device.go +++ b/internal/oauth/device.go @@ -75,9 +75,9 @@ func (d Discoverer) deviceLogin(ctx context.Context, opts LoginOptions, disc Dis if opts.Output != nil { 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) + fmt.Fprintf(opts.Output, "Open %s and confirm the code %s.\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) + fmt.Fprintf(opts.Output, "Open %s and enter the code %s.\n", verify, dc.UserCode) } } diff --git a/internal/oauth/login.go b/internal/oauth/login.go index 225441c..bc29c36 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -121,7 +121,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, disc.Issuer) + wait = loopbackWaiter(ln, disc.Issuer) } else if redirectURL == "" { redirectURL = "http://127.0.0.1:1/callback" } @@ -155,7 +155,7 @@ func (d Discoverer) codeLogin(ctx context.Context, opts LoginOptions, disc Disco } if opts.Output != nil { - fmt.Fprintf(opts.Output, "Opening browser for login.\nIf it does not open, visit:\n%s\n", authorizeURL) + fmt.Fprintf(opts.Output, "Opening the browser to log in. If it does not open, visit:\n%s\n", authorizeURL) } code, err := wait(ctx, redirectURL, state) @@ -206,7 +206,7 @@ func listenFirstFree(ports []int) (net.Listener, error) { return nil, fmt.Errorf("listen on loopback: none of ports %v is free (or use --device): %w", ports, errors.Join(errs...)) } -func loopbackWaiter(ln net.Listener, out io.Writer, issuer string) WaitCodeFunc { +func loopbackWaiter(ln net.Listener, issuer string) WaitCodeFunc { return func(ctx context.Context, _, wantState string) (string, error) { codeCh := make(chan string, 1) errCh := make(chan error, 1) @@ -237,10 +237,6 @@ func loopbackWaiter(ln net.Listener, out io.Writer, issuer string) WaitCodeFunc _ = srv.Shutdown(shutdownCtx) }() - if out != nil { - fmt.Fprintf(out, "Waiting for login to complete on %s\n", ln.Addr().String()) - } - select { case <-ctx.Done(): return "", ctx.Err() diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index 0370b0f..241713f 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -259,7 +259,7 @@ func TestLoopbackWaiter_Pages(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - wait := loopbackWaiter(ln, nil, "https://login.cloud.qdrant.io/") + wait := loopbackWaiter(ln, "https://login.cloud.qdrant.io/") done := make(chan struct{}) go func() { defer close(done) From 5f984c8bad11fa9b4626582b4e95e05b7d5bc25c Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 13:18:54 +0200 Subject: [PATCH 13/25] [CP-768] feat(auth): show the granted scope, next to the requested one The token's scope is what qcloud requested (manage by default); the consent page can narrow it. The post-login Action now puts the granted scope in the token (https://cloud.qdrant.io/granted_scope). qcloud reads it at login, stores it as granted_scope next to requested_scope in credentials.yaml (the stored scope field is renamed accordingly), and shows it: "Logged in as ..., account ..., read only." and in auth status. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/auth_test.go | 28 +++++++++++++++++++++++- internal/cmd/auth/login.go | 14 +++++++++--- internal/cmd/auth/status.go | 6 +++++- internal/cmd/auth/token.go | 2 +- internal/oauth/claims.go | 39 +++++++++++++++++++++++++--------- internal/oauth/claims_test.go | 18 +++++++++++----- internal/oauth/store.go | 12 +++++++---- 7 files changed, 94 insertions(+), 25 deletions(-) diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index 9458fbf..e365657 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -2,6 +2,7 @@ package auth_test import ( "context" + "encoding/base64" "encoding/json" "io" "net/http" @@ -68,6 +69,12 @@ func TestAuthDiscover_UsesGatewayMetadata(t *testing.T) { // prepareLogin points env at a fake authorization server that completes a browser login. func prepareLogin(t *testing.T, env *testutil.TestEnv) { + t.Helper() + prepareLoginWithToken(t, env, "access-from-login") +} + +// prepareLoginWithToken is prepareLogin with the access token the server issues. +func prepareLoginWithToken(t *testing.T, env *testutil.TestEnv, accessToken string) { t.Helper() t.Setenv("HOME", t.TempDir()) @@ -93,7 +100,7 @@ func prepareLogin(t *testing.T, env *testutil.TestEnv) { assert.Equal(t, "test-code", r.Form.Get("code")) _ = json.NewEncoder(w).Encode(map[string]any{ - "access_token": "access-from-login", + "access_token": accessToken, "refresh_token": "refresh-from-login", "token_type": "Bearer", "expires_in": 3600, @@ -160,6 +167,25 @@ func TestAuthLogin_RemembersTheOnlyGrantedAccount(t *testing.T) { assert.Equal(t, "acc-only", req.GetAccountId()) } +func TestAuthLogin_ShowsTheGrantedScope(t *testing.T) { + env := testutil.NewBareTestEnv(t) + claims := base64.RawURLEncoding.EncodeToString([]byte( + `{"user_email":"ada@example.com","https://cloud.qdrant.io/granted_scope":"read-only"}`)) + prepareLoginWithToken(t, env, "h."+claims+".s") + env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{ + Items: []*accountv1.Account{{Id: "acc-only", Name: "Only Account"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") + require.NoError(t, err) + assert.Contains(t, stdout, "Logged in as ada@example.com, account Only Account, read only.") + + statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) + require.NoError(t, err) + assert.Contains(t, statusOut, "Granted: read-only") + assert.Contains(t, statusOut, "Requested: manage") +} + func TestAuthLogin_SeveralGrantedAccountsAreNotChosen(t *testing.T) { env := testutil.NewBareTestEnv(t) prepareLogin(t, env) diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 94d09b6..1af2f9f 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -81,6 +81,9 @@ qcloud auth login --device`, return err } + claims := oauth.ReadTokenClaims(tok.AccessToken) + tok.GrantedScope = claims.GrantedScope + path := oauth.CredentialsPath(s.Config.ConfigFilePath()) if err := oauth.SaveToken(path, tok); err != nil { return err @@ -89,11 +92,16 @@ qcloud auth login --device`, account := rememberGrantedAccount(cmd, s, path, tok) who := "Logged in" - if email := oauth.TokenEmail(tok.AccessToken); email != "" { - who += " as " + email + if claims.Email != "" { + who += " as " + claims.Email + } + + granted := "" + if tok.GrantedScope != "" { + granted = ", " + oauth.ScopeLabel(tok.GrantedScope) } - fmt.Fprintf(cmd.OutOrStdout(), "%s%s.\n", who, account) + fmt.Fprintf(cmd.OutOrStdout(), "%s%s%s.\n", who, account, granted) return nil }, diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index 1564f5f..e437d7a 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -52,7 +52,11 @@ access token.`, } fmt.Fprintf(out, "Status: %s\n", status) - fmt.Fprintf(out, "Scope: %s\n", tok.Scope) + if tok.GrantedScope != "" { + fmt.Fprintf(out, "Granted: %s\n", tok.GrantedScope) + } + + fmt.Fprintf(out, "Requested: %s\n", tok.Scope) if tok.AccountID != "" { fmt.Fprintf(out, "Account: %s (default for commands)\n", tok.AccountID) } diff --git a/internal/cmd/auth/token.go b/internal/cmd/auth/token.go index 1c2a971..e153874 100644 --- a/internal/cmd/auth/token.go +++ b/internal/cmd/auth/token.go @@ -52,7 +52,7 @@ qcloud auth token --json`, } if s.Config.JSONOutput() { - fmt.Fprintf(cmd.OutOrStdout(), `{"access_token":%q,"token_type":%q,"scope":%q}`+"\n", tok.AccessToken, tok.TokenType, tok.Scope) + fmt.Fprintf(cmd.OutOrStdout(), `{"access_token":%q,"token_type":%q,"scope":%q,"granted_scope":%q}`+"\n", tok.AccessToken, tok.TokenType, tok.Scope, tok.GrantedScope) return nil } diff --git a/internal/oauth/claims.go b/internal/oauth/claims.go index 1b41a76..70d4829 100644 --- a/internal/oauth/claims.go +++ b/internal/oauth/claims.go @@ -6,25 +6,44 @@ import ( "strings" ) -// TokenEmail returns the user_email claim of an access token, for display only: the signature is not -// checked here (the API does that on every call). Empty when the token carries no email. -func TokenEmail(accessToken string) string { +// grantedScopeClaim is set by the Qdrant post-login Action from the consent the user gave. +const grantedScopeClaim = "https://cloud.qdrant.io/granted_scope" + +// TokenClaims are the access token claims qcloud shows to the user. +type TokenClaims struct { + Email string + GrantedScope string +} + +// ReadTokenClaims reads display claims from an access token. The signature is not checked here (the API +// does that on every call), so the values are for display only. Missing claims are empty. +func ReadTokenClaims(accessToken string) TokenClaims { parts := strings.Split(accessToken, ".") if len(parts) != 3 { - return "" + return TokenClaims{} } payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) if err != nil { - return "" + return TokenClaims{} } - var claims struct { - UserEmail string `json:"user_email"` + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + return TokenClaims{} } - if err := json.Unmarshal(payload, &claims); err != nil { - return "" + + email, _ := raw["user_email"].(string) + granted, _ := raw[grantedScopeClaim].(string) + + return TokenClaims{Email: email, GrantedScope: granted} +} + +// ScopeLabel is how a scope reads in a sentence: "read only" or "manage". +func ScopeLabel(scope string) string { + if scope == "read-only" { + return "read only" } - return claims.UserEmail + return scope } diff --git a/internal/oauth/claims_test.go b/internal/oauth/claims_test.go index 01f79bd..2245aa4 100644 --- a/internal/oauth/claims_test.go +++ b/internal/oauth/claims_test.go @@ -7,11 +7,19 @@ import ( "github.com/stretchr/testify/assert" ) -func TestTokenEmail(t *testing.T) { +func TestReadTokenClaims(t *testing.T) { t.Parallel() - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"user_email":"ada@example.com"}`)) - assert.Equal(t, "ada@example.com", TokenEmail("h."+payload+".s")) - assert.Empty(t, TokenEmail("opaque-token")) - assert.Empty(t, TokenEmail("h."+base64.RawURLEncoding.EncodeToString([]byte(`{}`))+".s")) + payload := base64.RawURLEncoding.EncodeToString( + []byte(`{"user_email":"ada@example.com","https://cloud.qdrant.io/granted_scope":"read-only"}`)) + assert.Equal(t, TokenClaims{Email: "ada@example.com", GrantedScope: "read-only"}, ReadTokenClaims("h."+payload+".s")) + assert.Equal(t, TokenClaims{}, ReadTokenClaims("opaque-token")) + assert.Equal(t, TokenClaims{}, ReadTokenClaims("h."+base64.RawURLEncoding.EncodeToString([]byte(`{}`))+".s")) +} + +func TestScopeLabel(t *testing.T) { + t.Parallel() + + assert.Equal(t, "read only", ScopeLabel("read-only")) + assert.Equal(t, "manage", ScopeLabel("manage")) } diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 873feef..afb5d11 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -18,10 +18,14 @@ type Token struct { RefreshToken string `yaml:"refresh_token,omitempty"` TokenType string `yaml:"token_type,omitempty"` Expiry time.Time `yaml:"expiry,omitempty"` - Scope string `yaml:"scope,omitempty"` - Issuer string `yaml:"issuer,omitempty"` - ClientID string `yaml:"client_id,omitempty"` - Resource string `yaml:"resource,omitempty"` + // Scope is the requested scope: what qcloud asked Auth0 for, and what the token's scope claim says. + Scope string `yaml:"requested_scope,omitempty"` + // GrantedScope is what the user granted on the consent page (read-only or manage), which can be + // narrower than the requested scope. iam-service enforces it on every call. + GrantedScope string `yaml:"granted_scope,omitempty"` + Issuer string `yaml:"issuer,omitempty"` + ClientID string `yaml:"client_id,omitempty"` + Resource string `yaml:"resource,omitempty"` // AccountID is the account the login was granted, when it was granted exactly one. Commands use it // when no account is set by flag, environment or context. AccountID string `yaml:"account_id,omitempty"` From 150c58fb98c10806620368e0413bddd619d3ef6d Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 13:22:48 +0200 Subject: [PATCH 14/25] [CP-768] feat(auth): status shows the context and where the account comes from auth status now names the active context, says when an API key is set (commands then use it instead of the login), and shows the account with its source: --account-id, QDRANT_CLOUD_ACCOUNT_ID, the context, or the login. OAuth sessions stay keyed by endpoint; a context selects them. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/auth_test.go | 38 ++++++++++++++++++++++++++++++++- internal/cmd/auth/status.go | 39 ++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index e365657..845bc9b 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -8,6 +8,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -157,7 +159,7 @@ func TestAuthLogin_RemembersTheOnlyGrantedAccount(t *testing.T) { statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) require.NoError(t, err) - assert.Contains(t, statusOut, "Account: acc-only") + assert.Contains(t, statusOut, "Account: acc-only (from the login)") // No --account-id: the command uses the account the login was granted. _, _, err = testutil.Exec(t, env, "cluster", "list", "--endpoint", devEndpoint) @@ -186,6 +188,40 @@ func TestAuthLogin_ShowsTheGrantedScope(t *testing.T) { assert.Contains(t, statusOut, "Requested: manage") } +func TestAuthStatus_ShowsWhereTheAccountComesFrom(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{ + Items: []*accountv1.Account{{Id: "acc-only", Name: "Only Account"}}, + }, nil) + + // A context that also sets an account, active through current_context. + configPath := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(`current_context: dev +contexts: + - name: dev + endpoint: `+devEndpoint+` + account_id: ctx-acc +`), 0o600)) + + _, _, err := testutil.Exec(t, env, "auth", "login", "--config", configPath, "--client-id", "cli-app") + require.NoError(t, err) + + statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--config", configPath) + require.NoError(t, err) + assert.Contains(t, statusOut, "Context: dev") + assert.Contains(t, statusOut, "Account: ctx-acc (from context dev)") + + statusOut, _, err = testutil.Exec(t, env, "auth", "status", "--config", configPath, "--account-id", "flag-acc") + require.NoError(t, err) + assert.Contains(t, statusOut, "Account: flag-acc (from --account-id)") + + t.Setenv("QDRANT_CLOUD_ACCOUNT_ID", "env-acc") + statusOut, _, err = testutil.Exec(t, env, "auth", "status", "--config", configPath) + require.NoError(t, err) + assert.Contains(t, statusOut, "Account: env-acc (from QDRANT_CLOUD_ACCOUNT_ID)") +} + func TestAuthLogin_SeveralGrantedAccountsAreNotChosen(t *testing.T) { env := testutil.NewBareTestEnv(t) prepareLogin(t, env) diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index e437d7a..ef7cb86 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "os" "time" "github.com/spf13/cobra" @@ -33,7 +34,14 @@ access token.`, out := cmd.OutOrStdout() fmt.Fprintf(out, "Endpoint: %s\n", disc.Hosts.GRPCEndpoint) + if name := s.Config.ActiveContext(); name != "" { + fmt.Fprintf(out, "Context: %s\n", name) + } + fmt.Fprintf(out, "Login URL: %s (%s)\n", disc.Issuer, disc.Source) + if s.Config.APIKey() != "" { + fmt.Fprintln(out, "Note: an API key is set; commands use it instead of this login") + } path := oauth.CredentialsPath(s.Config.ConfigFilePath()) tok, ok, err := oauth.LoadToken(path, s.Config.Endpoint()) @@ -57,8 +65,10 @@ access token.`, } fmt.Fprintf(out, "Requested: %s\n", tok.Scope) - if tok.AccountID != "" { - fmt.Fprintf(out, "Account: %s (default for commands)\n", tok.AccountID) + if id, source := accountSource(cmd, s, tok); id != "" { + fmt.Fprintf(out, "Account: %s (from %s)\n", id, source) + } else { + fmt.Fprintln(out, "Account: none; pass --account-id") } if !tok.Expiry.IsZero() { @@ -69,3 +79,28 @@ access token.`, }, }.CobraCommand(s) } + +// accountSource returns the account commands will use, and where it comes from, in the order the +// config resolves it: the --account-id flag, QDRANT_CLOUD_ACCOUNT_ID, the active context, and last the +// account the login was granted. +func accountSource(cmd *cobra.Command, s *state.State, tok oauth.Token) (string, string) { + if f := cmd.Flag("account-id"); f != nil && f.Changed { + return s.Config.AccountID(), "--account-id" + } + + if id := os.Getenv("QDRANT_CLOUD_ACCOUNT_ID"); id != "" { + return id, "QDRANT_CLOUD_ACCOUNT_ID" + } + + if name := s.Config.ActiveContext(); name != "" { + if entry, ok := s.Config.GetContext(name); ok && entry.AccountID != "" { + return entry.AccountID, "context " + name + } + } + + if tok.AccountID != "" && s.Config.APIKey() == "" { + return tok.AccountID, "the login" + } + + return "", "" +} From 8d5b46a8e85f98f058acccce923f50dee245b1cc Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 13:27:11 +0200 Subject: [PATCH 15/25] [CP-768] feat(auth): status shows only the granted access The requested scope (what qcloud asked Auth0 for) next to the granted one only confused; it stays in credentials.yaml and auth token --json. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/auth_test.go | 4 ++-- internal/cmd/auth/status.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index 845bc9b..cdfc969 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -184,8 +184,8 @@ func TestAuthLogin_ShowsTheGrantedScope(t *testing.T) { statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) require.NoError(t, err) - assert.Contains(t, statusOut, "Granted: read-only") - assert.Contains(t, statusOut, "Requested: manage") + assert.Contains(t, statusOut, "Access: read only") + assert.NotContains(t, statusOut, "Requested") } func TestAuthStatus_ShowsWhereTheAccountComesFrom(t *testing.T) { diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index ef7cb86..66ce0ad 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -60,11 +60,11 @@ access token.`, } fmt.Fprintf(out, "Status: %s\n", status) + // The access the user granted on the consent page. The requested scope (what qcloud asked for) + // is in credentials.yaml; showing it here next to this only confuses. if tok.GrantedScope != "" { - fmt.Fprintf(out, "Granted: %s\n", tok.GrantedScope) + fmt.Fprintf(out, "Access: %s\n", oauth.ScopeLabel(tok.GrantedScope)) } - - fmt.Fprintf(out, "Requested: %s\n", tok.Scope) if id, source := accountSource(cmd, s, tok); id != "" { fmt.Fprintf(out, "Account: %s (from %s)\n", id, source) } else { From b73b8d3ec2fd383571fe3a623fd6b8b9e8f74ff9 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 13:27:44 +0200 Subject: [PATCH 16/25] [CP-768] fix(auth): lint, blank line after the access block Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/status.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index 66ce0ad..c691489 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -65,6 +65,7 @@ access token.`, if tok.GrantedScope != "" { fmt.Fprintf(out, "Access: %s\n", oauth.ScopeLabel(tok.GrantedScope)) } + if id, source := accountSource(cmd, s, tok); id != "" { fmt.Fprintf(out, "Account: %s (from %s)\n", id, source) } else { From e38caf1860c7088366ea56eb3d0efa9c29d63e1d Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 15:57:37 +0200 Subject: [PATCH 17/25] [CP-768] feat(auth): a context per granted account after login auth login creates an OAuth context (auth: oauth, no API key) for every account granted on the consent page, named -. With one account it becomes the current context unless one is already set; with several, the login lists them for qcloud context use. A later login updates them and removes those of accounts no longer granted; auth logout removes them. API key contexts and other endpoints' contexts are never touched; a name clash gets a suffix. A context now has exactly one credential: an API key (or command) or --oauth, never both, never none. context show/list show the credential. The account stored on the OAuth session is gone: contexts replace it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/qcloud_auth_login.md | 10 +- docs/reference/qcloud_context_set.md | 12 +- internal/cmd/auth/auth_test.go | 169 +++++++++++++------- internal/cmd/auth/contexts.go | 140 ++++++++++++++++ internal/cmd/auth/contexts_internal_test.go | 28 ++++ internal/cmd/auth/login.go | 87 ++++++---- internal/cmd/auth/logout.go | 12 ++ internal/cmd/auth/status.go | 14 +- internal/cmd/context/list.go | 7 + internal/cmd/context/set.go | 53 +++--- internal/cmd/context/set_test.go | 36 +++++ internal/cmd/context/show.go | 10 +- internal/oauth/store.go | 3 - internal/state/config/config.go | 38 +++++ internal/state/state.go | 17 +- 15 files changed, 497 insertions(+), 139 deletions(-) create mode 100644 internal/cmd/auth/contexts.go create mode 100644 internal/cmd/auth/contexts_internal_test.go diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md index 86f3ed9..0f335d3 100644 --- a/docs/reference/qcloud_auth_login.md +++ b/docs/reference/qcloud_auth_login.md @@ -22,9 +22,13 @@ The Qdrant Cloud CLI client id is built in per login URL. --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID override it, for a tenant without a built-in id. Tokens are stored in credentials.yaml next to the config file (mode 0600). -When the login is granted exactly one account, that account is stored with the -session and used by commands when no --account-id, QDRANT_CLOUD_ACCOUNT_ID or -context sets one ("qcloud auth status" shows it). +After the login, qcloud creates a context for every account you granted on the +consent page (named after the account, prefixed with the environment, e.g. +dev-my-account), marked as OAuth so it needs no API key. With one account its +context becomes the current one, unless you already have a current context; +with several, pick one with "qcloud context use ". A later login updates +these contexts, and "qcloud auth logout" removes them. Contexts with an API key +are never changed. ``` qcloud auth login [flags] diff --git a/docs/reference/qcloud_context_set.md b/docs/reference/qcloud_context_set.md index ee6a24f..65f08cc 100644 --- a/docs/reference/qcloud_context_set.md +++ b/docs/reference/qcloud_context_set.md @@ -6,9 +6,11 @@ Create or update a context Create or update a named context in the configuration file. -A context stores connection settings (endpoint, account ID) and API key -credentials under a name, so you can switch between environments with -"qcloud context use ". +A context stores connection settings (endpoint, account ID) and one credential +under a name, so you can switch between environments with +"qcloud context use ". The credential is either an API key or, with +--oauth, the OAuth login for the endpoint ("qcloud auth login", which also +creates OAuth contexts for your accounts itself); never both, never none. There are three ways to provide the API key: @@ -49,6 +51,9 @@ qcloud context set staging --api-key-command 'op read op://vault/qdrant/api-key' # Use a named helper preset qcloud context set staging --api-key-helper 1password --api-key-ref op://vault/qdrant/api-key --account-id acc-123 + +# Use the OAuth login for the endpoint instead of an API key +qcloud context set dev --endpoint grpc.development-cloud.qdrant.io:443 --account-id acc-123 --oauth ``` ### Options @@ -61,6 +66,7 @@ qcloud context set staging --api-key-helper 1password --api-key-ref op://vault/q --api-key-ref string Secret reference for the credential helper --endpoint string API endpoint for this context -h, --help help for set + --oauth Use the OAuth login for the endpoint (qcloud auth login) instead of an API key ``` ### Options inherited from parent commands diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index cdfc969..132bf15 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -121,52 +121,71 @@ func prepareLoginWithToken(t *testing.T, env *testutil.TestEnv, accessToken stri const devEndpoint = "grpc.development-cloud.qdrant.io:443" +// tempConfig writes a config file for the test and returns its path; credentials.yaml goes next to it. +func tempConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + return path +} + +func grantAccounts(env *testutil.TestEnv, accounts ...*accountv1.Account) { + env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{Items: accounts}, nil) +} + func TestAuthLogin_StoresToken(t *testing.T) { env := testutil.NewBareTestEnv(t) prepareLogin(t, env) - env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{}, nil) + grantAccounts(env) + cfg := tempConfig(t, "") - stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") + stdout, _, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) - assert.Contains(t, stdout, "Logged in") + assert.Contains(t, stdout, "Logged in; no accounts were granted.") - tokenOut, _, err := testutil.Exec(t, env, "auth", "token", "--endpoint", devEndpoint) + tokenOut, _, err := testutil.Exec(t, env, "auth", "token", "--config", cfg, "--endpoint", devEndpoint) require.NoError(t, err) assert.Equal(t, "access-from-login\n", tokenOut) - statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) + statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--config", cfg, "--endpoint", devEndpoint) require.NoError(t, err) assert.Contains(t, statusOut, "logged in") - _, _, err = testutil.Exec(t, env, "auth", "logout", "--endpoint", devEndpoint) + _, _, err = testutil.Exec(t, env, "auth", "logout", "--config", cfg, "--endpoint", devEndpoint) require.NoError(t, err) - _, _, err = testutil.Exec(t, env, "auth", "token", "--endpoint", devEndpoint) + _, _, err = testutil.Exec(t, env, "auth", "token", "--config", cfg, "--endpoint", devEndpoint) require.Error(t, err) } -func TestAuthLogin_RemembersTheOnlyGrantedAccount(t *testing.T) { +func TestAuthLogin_CreatesAndSelectsTheContextOfTheOnlyAccount(t *testing.T) { env := testutil.NewBareTestEnv(t) prepareLogin(t, env) - env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{ - Items: []*accountv1.Account{{Id: "acc-only", Name: "Only Account"}}, - }, nil) + grantAccounts(env, &accountv1.Account{Id: "acc-only", Name: "Only Account"}) env.Server.ListClustersCalls.Returns(&clusterv1.ListClustersResponse{}, nil) + cfg := tempConfig(t, "") - stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") - require.NoError(t, err) - assert.Contains(t, stdout, "Logged in, account Only Account.") - - statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) + stdout, _, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) - assert.Contains(t, statusOut, "Account: acc-only (from the login)") + assert.Contains(t, stdout, "Logged in, account Only Account (context dev-only-account).") + assert.NotContains(t, stdout, "Switch to it") - // No --account-id: the command uses the account the login was granted. - _, _, err = testutil.Exec(t, env, "cluster", "list", "--endpoint", devEndpoint) + // The context is current: no --endpoint, no --account-id. + _, _, err = testutil.Exec(t, env, "cluster", "list", "--config", cfg) require.NoError(t, err) req, ok := env.Server.ListClustersCalls.Last() require.True(t, ok) assert.Equal(t, "acc-only", req.GetAccountId()) + + statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--config", cfg) + require.NoError(t, err) + assert.Contains(t, statusOut, "Context: dev-only-account") + assert.Contains(t, statusOut, "Account: acc-only (from context dev-only-account)") + + listOut, _, err := testutil.Exec(t, env, "context", "list", "--config", cfg) + require.NoError(t, err) + assert.Regexp(t, `\*\s+dev-only-account\s+oauth`, listOut) } func TestAuthLogin_ShowsTheGrantedScope(t *testing.T) { @@ -174,76 +193,118 @@ func TestAuthLogin_ShowsTheGrantedScope(t *testing.T) { claims := base64.RawURLEncoding.EncodeToString([]byte( `{"user_email":"ada@example.com","https://cloud.qdrant.io/granted_scope":"read-only"}`)) prepareLoginWithToken(t, env, "h."+claims+".s") - env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{ - Items: []*accountv1.Account{{Id: "acc-only", Name: "Only Account"}}, - }, nil) + grantAccounts(env, &accountv1.Account{Id: "acc-only", Name: "Only Account"}) + cfg := tempConfig(t, "") - stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") + stdout, _, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) - assert.Contains(t, stdout, "Logged in as ada@example.com, account Only Account, read only.") + assert.Contains(t, stdout, "Logged in as ada@example.com, account Only Account, read only (context dev-only-account).") - statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--endpoint", devEndpoint) + statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--config", cfg) require.NoError(t, err) assert.Contains(t, statusOut, "Access: read only") assert.NotContains(t, statusOut, "Requested") } -func TestAuthStatus_ShowsWhereTheAccountComesFrom(t *testing.T) { +func TestAuthLogin_SeveralAccountsGetAContextEachAndNoneIsSelected(t *testing.T) { env := testutil.NewBareTestEnv(t) prepareLogin(t, env) - env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{ - Items: []*accountv1.Account{{Id: "acc-only", Name: "Only Account"}}, - }, nil) + grantAccounts(env, + &accountv1.Account{Id: "a1", Name: "Robert Stam - Base Account"}, + &accountv1.Account{Id: "a2", Name: "Andrey Vasnetsov - Base Account"}, + ) + cfg := tempConfig(t, "") + + stdout, _, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") + require.NoError(t, err) + assert.Contains(t, stdout, "Contexts for your 2 accounts:") + assert.Contains(t, stdout, "dev-robert-stam-base-account") + assert.Contains(t, stdout, "dev-andrey-vasnetsov-base-account") + assert.Contains(t, stdout, "Select one with: qcloud context use ") + + _, _, err = testutil.Exec(t, env, "cluster", "list", "--config", cfg, "--endpoint", devEndpoint) + require.Error(t, err) + assert.Contains(t, err.Error(), "no account ID configured") +} - // A context that also sets an account, active through current_context. - configPath := filepath.Join(t.TempDir(), "config.yaml") - require.NoError(t, os.WriteFile(configPath, []byte(`current_context: dev +func TestAuthLogin_UpdatesOnlyItsOwnContexts(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + // An API key context with the name the login would pick, current; and an OAuth context of an + // account that is no longer granted. + cfg := tempConfig(t, `current_context: dev-only-account contexts: - - name: dev + - name: dev-only-account + endpoint: `+devEndpoint+` + api_key: secret + account_id: key-acc + - name: dev-old-account endpoint: `+devEndpoint+` - account_id: ctx-acc -`), 0o600)) + account_id: old-acc + auth: oauth +`) + grantAccounts(env, &accountv1.Account{Id: "acc-only", Name: "Only Account"}) + + // --context picks the OAuth context for this run: with the API key context active, the login would use + // the key and look nothing up. + stdout, stderr, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, + "--client-id", "cli-app", "--context", "dev-old-account") + require.NoError(t, err) + assert.NotContains(t, stderr, "Could not list your accounts") + assert.Contains(t, stdout, "account Only Account (context dev-only-account-2).") + assert.Contains(t, stdout, "Switch to it with: qcloud context use dev-only-account-2") - _, _, err := testutil.Exec(t, env, "auth", "login", "--config", configPath, "--client-id", "cli-app") + listOut, _, err := testutil.Exec(t, env, "context", "list", "--config", cfg) require.NoError(t, err) + assert.Regexp(t, `\*\s+dev-only-account\s+api key`, listOut, "the API key context is untouched and still current") + assert.Regexp(t, `dev-only-account-2\s+oauth`, listOut) + assert.NotContains(t, listOut, "dev-old-account", "the context of an account no longer granted is removed") +} - statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--config", configPath) +func TestAuthLogout_RemovesTheOAuthContexts(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + grantAccounts(env, &accountv1.Account{Id: "acc-only", Name: "Only Account"}) + cfg := tempConfig(t, "") + + _, _, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) - assert.Contains(t, statusOut, "Context: dev") - assert.Contains(t, statusOut, "Account: ctx-acc (from context dev)") - statusOut, _, err = testutil.Exec(t, env, "auth", "status", "--config", configPath, "--account-id", "flag-acc") + stdout, _, err := testutil.Exec(t, env, "auth", "logout", "--config", cfg, "--endpoint", devEndpoint) require.NoError(t, err) - assert.Contains(t, statusOut, "Account: flag-acc (from --account-id)") + assert.Contains(t, stdout, "Logged out; removed contexts dev-only-account.") - t.Setenv("QDRANT_CLOUD_ACCOUNT_ID", "env-acc") - statusOut, _, err = testutil.Exec(t, env, "auth", "status", "--config", configPath) + listOut, _, err := testutil.Exec(t, env, "context", "list", "--config", cfg) require.NoError(t, err) - assert.Contains(t, statusOut, "Account: env-acc (from QDRANT_CLOUD_ACCOUNT_ID)") + assert.NotContains(t, listOut, "dev-only-account") } -func TestAuthLogin_SeveralGrantedAccountsAreNotChosen(t *testing.T) { +func TestAuthStatus_ShowsWhereTheAccountComesFrom(t *testing.T) { env := testutil.NewBareTestEnv(t) prepareLogin(t, env) - env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{ - Items: []*accountv1.Account{{Id: "a1", Name: "One"}, {Id: "a2", Name: "Two"}}, - }, nil) + grantAccounts(env, &accountv1.Account{Id: "acc-only", Name: "Only Account"}) + cfg := tempConfig(t, "") - stdout, _, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") + _, _, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) - assert.Contains(t, stdout, "Logged in; 2 accounts available, pass --account-id.") - _, _, err = testutil.Exec(t, env, "cluster", "list", "--endpoint", devEndpoint) - require.Error(t, err) - assert.Contains(t, err.Error(), "no account ID configured") + statusOut, _, err := testutil.Exec(t, env, "auth", "status", "--config", cfg, "--account-id", "flag-acc") + require.NoError(t, err) + assert.Contains(t, statusOut, "Account: flag-acc (from --account-id)") + + t.Setenv("QDRANT_CLOUD_ACCOUNT_ID", "env-acc") + statusOut, _, err = testutil.Exec(t, env, "auth", "status", "--config", cfg) + require.NoError(t, err) + assert.Contains(t, statusOut, "Account: env-acc (from QDRANT_CLOUD_ACCOUNT_ID)") } func TestAuthLogin_AccountLookupFailureDoesNotFailLogin(t *testing.T) { env := testutil.NewBareTestEnv(t) prepareLogin(t, env) env.AccountServer.ListAccountsCalls.Returns(nil, status.Error(codes.Unavailable, "down")) + cfg := tempConfig(t, "") - stdout, stderr, err := testutil.Exec(t, env, "auth", "login", "--endpoint", devEndpoint, "--client-id", "cli-app") + stdout, stderr, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) assert.Contains(t, stdout, "Logged in") assert.Contains(t, stderr, "Could not list your accounts") diff --git a/internal/cmd/auth/contexts.go b/internal/cmd/auth/contexts.go new file mode 100644 index 0000000..f6a3852 --- /dev/null +++ b/internal/cmd/auth/contexts.go @@ -0,0 +1,140 @@ +package auth + +import ( + "fmt" + "net" + "regexp" + "strings" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/state" + "github.com/qdrant/qcloud-cli/internal/state/config" +) + +// maxContextNameLength keeps generated context names readable. +const maxContextNameLength = 60 + +var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) + +// slug turns an account name into a context name part: lowercase, with dashes. +func slug(name string) string { + s := strings.Trim(nonSlugChars.ReplaceAllString(strings.ToLower(name), "-"), "-") + if len(s) > maxContextNameLength { + s = strings.TrimRight(s[:maxContextNameLength], "-") + } + + return s +} + +// contextPrefix names the environment of an endpoint: none for production (grpc.cloud.qdrant.io), +// "dev-" for grpc.development-cloud.qdrant.io, "staging-" for grpc.staging-cloud.qdrant.io, and the +// host otherwise. +func contextPrefix(endpoint string) string { + host := endpoint + if h, _, err := net.SplitHostPort(endpoint); err == nil { + host = h + } + + cluster, ok := strings.CutPrefix(host, "grpc.") + if ok { + if cluster, ok = strings.CutSuffix(cluster, ".qdrant.io"); ok { + switch cluster = strings.TrimSuffix(cluster, "-cloud"); cluster { + case "cloud", "": + return "" + case "development": + return "dev-" + default: + return slug(cluster) + "-" + } + } + } + + return slug(host) + "-" +} + +// syncOAuthContexts keeps one OAuth context per granted account for endpoint: it creates a context for +// a new account, keeps the name of an existing one, and removes the OAuth contexts of accounts that are +// no longer granted. Contexts with an API key, and OAuth contexts of other endpoints, are never touched. +// With exactly one account, its context becomes the current one unless another context already is. +// It returns the context names in account order, and the name it made current, if any. +func syncOAuthContexts(s *state.State, endpoint string, accounts []*accountv1.Account) ([]string, string, error) { + existingByAccount := map[string]string{} + taken := map[string]bool{} + for _, ctx := range s.Config.Contexts() { + taken[ctx.Name] = true + if ctx.IsOAuth() && ctx.Endpoint == endpoint { + existingByAccount[ctx.AccountID] = ctx.Name + } + } + + granted := map[string]bool{} + names := make([]string, 0, len(accounts)) + for _, account := range accounts { + granted[account.GetId()] = true + + name, ok := existingByAccount[account.GetId()] + if !ok { + name = uniqueContextName(contextPrefix(endpoint), account, taken) + taken[name] = true + } + + s.Config.UpsertContext(config.ContextEntry{ + Name: name, + Endpoint: endpoint, + AccountID: account.GetId(), + Auth: config.AuthOAuth, + }) + names = append(names, name) + } + + for accountID, name := range existingByAccount { + if !granted[accountID] { + s.Config.DeleteContext(name) + } + } + + selected := "" + if len(names) == 1 && s.Config.CurrentContext() == "" { + s.Config.SetCurrentContext(names[0]) + selected = names[0] + } + + if err := s.Config.WriteToFile(); err != nil { + return nil, "", err + } + + return names, selected, nil +} + +// uniqueContextName is prefix + the account's name, with a suffix when that name is taken. +func uniqueContextName(prefix string, account *accountv1.Account, taken map[string]bool) string { + base := slug(account.GetName()) + if base == "" { + base = "account-" + slug(account.GetId()) + } + + name := prefix + base + for i := 2; taken[name]; i++ { + name = fmt.Sprintf("%s%s-%d", prefix, base, i) + } + + return name +} + +// removeOAuthContexts deletes the OAuth contexts of endpoint and returns their names. +func removeOAuthContexts(s *state.State, endpoint string) ([]string, error) { + var removed []string + for _, ctx := range s.Config.Contexts() { + if ctx.IsOAuth() && ctx.Endpoint == endpoint { + s.Config.DeleteContext(ctx.Name) + removed = append(removed, ctx.Name) + } + } + + if len(removed) == 0 { + return nil, nil + } + + return removed, s.Config.WriteToFile() +} diff --git a/internal/cmd/auth/contexts_internal_test.go b/internal/cmd/auth/contexts_internal_test.go new file mode 100644 index 0000000..eda02fa --- /dev/null +++ b/internal/cmd/auth/contexts_internal_test.go @@ -0,0 +1,28 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContextPrefix(t *testing.T) { + t.Parallel() + + for endpoint, want := range map[string]string{ + "grpc.cloud.qdrant.io:443": "", + "grpc.development-cloud.qdrant.io:443": "dev-", + "grpc.staging-cloud.qdrant.io:443": "staging-", + "localhost:50051": "localhost-", + } { + assert.Equal(t, want, contextPrefix(endpoint), endpoint) + } +} + +func TestSlug(t *testing.T) { + t.Parallel() + + assert.Equal(t, "robert-stam-base-account", slug("Robert Stam - Base Account")) + assert.Equal(t, "cloud-test-functional-qdrant-com-base-account", slug("cloud-test+functional@qdrant.com - Base Account")) + assert.Empty(t, slug(" -- ")) +} diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 1af2f9f..87651b2 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -35,9 +35,13 @@ The Qdrant Cloud CLI client id is built in per login URL. --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID override it, for a tenant without a built-in id. Tokens are stored in credentials.yaml next to the config file (mode 0600). -When the login is granted exactly one account, that account is stored with the -session and used by commands when no --account-id, QDRANT_CLOUD_ACCOUNT_ID or -context sets one ("qcloud auth status" shows it).`, +After the login, qcloud creates a context for every account you granted on the +consent page (named after the account, prefixed with the environment, e.g. +dev-my-account), marked as OAuth so it needs no API key. With one account its +context becomes the current one, unless you already have a current context; +with several, pick one with "qcloud context use ". A later login updates +these contexts, and "qcloud auth logout" removes them. Contexts with an API key +are never changed.`, Example: `# Browser login (default production endpoint) qcloud auth login @@ -89,19 +93,29 @@ qcloud auth login --device`, return err } - account := rememberGrantedAccount(cmd, s, path, tok) - who := "Logged in" if claims.Email != "" { who += " as " + claims.Email } - granted := "" + access := "" if tok.GrantedScope != "" { - granted = ", " + oauth.ScopeLabel(tok.GrantedScope) + access = ", " + oauth.ScopeLabel(tok.GrantedScope) + } + + accounts, ok := grantedAccounts(cmd, s) + if !ok { + fmt.Fprintf(cmd.OutOrStdout(), "%s%s.\n", who, access) + return nil + } + + endpoint := s.Config.Endpoint() + names, selected, err := syncOAuthContexts(s, endpoint, accounts) + if err != nil { + return fmt.Errorf("logged in, but could not write the contexts: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "%s%s%s.\n", who, account, granted) + printLoginResult(cmd, who, access, accounts, names, selected) return nil }, @@ -111,41 +125,48 @@ qcloud auth login --device`, // accountLookupTimeout bounds the account lookup after login; the login itself has already succeeded. const accountLookupTimeout = 15 * time.Second -// rememberGrantedAccount stores the account the login was granted when it was granted exactly one, so -// commands need no --account-id, and returns the text the login line ends with. It never fails the -// login: without it, commands ask for an account as before. An API key takes precedence over the OAuth -// session, so then there is nothing to look up. -func rememberGrantedAccount(cmd *cobra.Command, s *state.State, path string, tok oauth.Token) string { +// grantedAccounts lists the accounts the login was granted. It never fails the login: on an error it +// warns and returns false. An API key takes precedence over the OAuth login, so then there is nothing to +// look up. +func grantedAccounts(cmd *cobra.Command, s *state.State) ([]*accountv1.Account, bool) { if s.Config.APIKey() != "" { - return "" + fmt.Fprintln(cmd.ErrOrStderr(), "An API key is set, so commands use it instead of this login; no contexts were created.") + return nil, false } - stderr := cmd.ErrOrStderr() ctx, cancel := context.WithTimeout(cmd.Context(), accountLookupTimeout) defer cancel() client, err := s.Client(ctx) - if err != nil { - fmt.Fprintf(stderr, "Could not list your accounts (%v); pass --account-id to commands.\n", err) - return "" + if err == nil { + var resp *accountv1.ListAccountsResponse + if resp, err = client.Account().ListAccounts(ctx, &accountv1.ListAccountsRequest{}); err == nil { + return resp.GetItems(), true + } } - resp, err := client.Account().ListAccounts(ctx, &accountv1.ListAccountsRequest{}) - if err != nil { - fmt.Fprintf(stderr, "Could not list your accounts (%v); pass --account-id to commands.\n", err) - return "" - } + fmt.Fprintf(cmd.ErrOrStderr(), "Could not list your accounts (%v); no contexts were created, pass --account-id to commands.\n", err) - accounts := resp.GetItems() - if len(accounts) != 1 { - return fmt.Sprintf("; %d accounts available, pass --account-id", len(accounts)) - } + return nil, false +} - tok.AccountID = accounts[0].GetId() - if err := oauth.SaveToken(path, tok); err != nil { - fmt.Fprintf(stderr, "Could not store the account: %v\n", err) - return "" +// printLoginResult reports the login and the contexts it created, one line per context. +func printLoginResult(cmd *cobra.Command, who, access string, accounts []*accountv1.Account, names []string, selected string) { + out := cmd.OutOrStdout() + switch len(names) { + case 0: + fmt.Fprintf(out, "%s%s; no accounts were granted.\n", who, access) + case 1: + fmt.Fprintf(out, "%s, account %s%s (context %s).\n", who, accounts[0].GetName(), access, names[0]) + if selected == "" { + fmt.Fprintf(out, "Switch to it with: qcloud context use %s\n", names[0]) + } + default: + fmt.Fprintf(out, "%s%s. Contexts for your %d accounts:\n", who, access, len(names)) + for i, name := range names { + fmt.Fprintf(out, " %s (%s)\n", name, accounts[i].GetName()) + } + + fmt.Fprintln(out, "Select one with: qcloud context use ") } - - return ", account " + accounts[0].GetName() } diff --git a/internal/cmd/auth/logout.go b/internal/cmd/auth/logout.go index 48eaf01..29b8628 100644 --- a/internal/cmd/auth/logout.go +++ b/internal/cmd/auth/logout.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "strings" "github.com/spf13/cobra" @@ -43,6 +44,17 @@ changed.`, return err } + // The contexts the login created use this login; without it they cannot work. + removed, err := removeOAuthContexts(s, endpoint) + if err != nil { + return err + } + + if len(removed) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Logged out; removed contexts %s.\n", strings.Join(removed, ", ")) + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), "Logged out.") return nil }, diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index c691489..dbddfda 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -66,10 +66,10 @@ access token.`, fmt.Fprintf(out, "Access: %s\n", oauth.ScopeLabel(tok.GrantedScope)) } - if id, source := accountSource(cmd, s, tok); id != "" { + if id, source := accountSource(cmd, s); id != "" { fmt.Fprintf(out, "Account: %s (from %s)\n", id, source) } else { - fmt.Fprintln(out, "Account: none; pass --account-id") + fmt.Fprintln(out, "Account: none; select a context with \"qcloud context use \" or pass --account-id") } if !tok.Expiry.IsZero() { @@ -82,9 +82,9 @@ access token.`, } // accountSource returns the account commands will use, and where it comes from, in the order the -// config resolves it: the --account-id flag, QDRANT_CLOUD_ACCOUNT_ID, the active context, and last the -// account the login was granted. -func accountSource(cmd *cobra.Command, s *state.State, tok oauth.Token) (string, string) { +// config resolves it: the --account-id flag, QDRANT_CLOUD_ACCOUNT_ID, then the active context (which +// auth login creates for each granted account). +func accountSource(cmd *cobra.Command, s *state.State) (string, string) { if f := cmd.Flag("account-id"); f != nil && f.Changed { return s.Config.AccountID(), "--account-id" } @@ -99,9 +99,5 @@ func accountSource(cmd *cobra.Command, s *state.State, tok oauth.Token) (string, } } - if tok.AccountID != "" && s.Config.APIKey() == "" { - return tok.AccountID, "the login" - } - return "", "" } diff --git a/internal/cmd/context/list.go b/internal/cmd/context/list.go index 5839dbc..bfb80b2 100644 --- a/internal/cmd/context/list.go +++ b/internal/cmd/context/list.go @@ -38,6 +38,13 @@ qcloud context list`, t.AddField("NAME", func(name string) string { return name }) + t.AddField("CREDENTIAL", func(name string) string { + if ctx, ok := s.Config.GetContext(name); ok && ctx.IsOAuth() { + return "oauth" + } + + return "api key" + }) t.Write(names) return nil }, diff --git a/internal/cmd/context/set.go b/internal/cmd/context/set.go index 5578506..c0d4649 100644 --- a/internal/cmd/context/set.go +++ b/internal/cmd/context/set.go @@ -1,7 +1,6 @@ package context import ( - "errors" "fmt" "regexp" "sort" @@ -12,6 +11,7 @@ import ( "github.com/qdrant/qcloud-cli/internal/cmd/base" "github.com/qdrant/qcloud-cli/internal/cmd/util" "github.com/qdrant/qcloud-cli/internal/state" + "github.com/qdrant/qcloud-cli/internal/state/config" ) // apiKeyHelpers maps --api-key-helper names to command templates. @@ -35,16 +35,21 @@ qcloud context set staging --api-key sk-... --account-id acc-123 qcloud context set staging --api-key-command 'op read op://vault/qdrant/api-key' --account-id acc-123 # Use a named helper preset -qcloud context set staging --api-key-helper 1password --api-key-ref op://vault/qdrant/api-key --account-id acc-123`, +qcloud context set staging --api-key-helper 1password --api-key-ref op://vault/qdrant/api-key --account-id acc-123 + +# Use the OAuth login for the endpoint instead of an API key +qcloud context set dev --endpoint grpc.development-cloud.qdrant.io:443 --account-id acc-123 --oauth`, BaseCobraCommand: func() *cobra.Command { cmd := &cobra.Command{ Use: "set ", Short: "Create or update a context", Long: `Create or update a named context in the configuration file. -A context stores connection settings (endpoint, account ID) and API key -credentials under a name, so you can switch between environments with -"qcloud context use ". +A context stores connection settings (endpoint, account ID) and one credential +under a name, so you can switch between environments with +"qcloud context use ". The credential is either an API key or, with +--oauth, the OAuth login for the endpoint ("qcloud auth login", which also +creates OAuth contexts for your accounts itself); never both, never none. There are three ways to provide the API key: @@ -83,10 +88,14 @@ still take precedence over the command at runtime.`, }) cmd.Flags().String("api-key-ref", "", "Secret reference for the credential helper") cmd.Flags().String("account-id", "", "Account ID for this context") + cmd.Flags().Bool("oauth", false, "Use the OAuth login for the endpoint (qcloud auth login) instead of an API key") cmd.MarkFlagsRequiredTogether("api-key-helper", "api-key-ref") cmd.MarkFlagsMutuallyExclusive("api-key", "api-key-command") cmd.MarkFlagsMutuallyExclusive("api-key", "api-key-helper") cmd.MarkFlagsMutuallyExclusive("api-key-command", "api-key-helper") + cmd.MarkFlagsMutuallyExclusive("oauth", "api-key") + cmd.MarkFlagsMutuallyExclusive("oauth", "api-key-command") + cmd.MarkFlagsMutuallyExclusive("oauth", "api-key-helper") return cmd }, Run: func(s *state.State, cmd *cobra.Command, args []string) error { @@ -106,14 +115,24 @@ still take precedence over the command at runtime.`, return err } - if apiKeyCommand != "" { - ctx.APIKeyCommand = apiKeyCommand + useOAuth, _ := cmd.Flags().GetBool("oauth") + switch { + case useOAuth: + ctx.Auth = config.AuthOAuth ctx.APIKey = "" - } else if key, ok := flagChangedWithValue(cmd, "api-key"); ok { - ctx.APIKey = key ctx.APIKeyCommand = "" - } else if ctx.APIKey == "" && ctx.APIKeyCommand == "" { - ctx.APIKey = s.Config.APIKey() + case apiKeyCommand != "": + ctx.APIKeyCommand = apiKeyCommand + ctx.APIKey = "" + ctx.Auth = "" + default: + if key, ok := flagChangedWithValue(cmd, "api-key"); ok { + ctx.APIKey = key + ctx.APIKeyCommand = "" + ctx.Auth = "" + } else if !ctx.IsOAuth() && ctx.APIKey == "" && ctx.APIKeyCommand == "" { + ctx.APIKey = s.Config.APIKey() + } } if id, ok := flagChangedWithValue(cmd, "account-id"); ok { @@ -122,16 +141,8 @@ still take precedence over the command at runtime.`, ctx.AccountID = s.Config.AccountID() } - if ctx.Endpoint == "" { - return errors.New("cannot set a context with an empty endpoint") - } - - if ctx.APIKey == "" && ctx.APIKeyCommand == "" { - return errors.New("cannot set a context with an empty API key (use --api-key, --api-key-command, or --api-key-helper)") - } - - if ctx.AccountID == "" { - return errors.New("cannot set a context with an empty account id") + if err := ctx.Validate(); err != nil { + return err } s.Config.UpsertContext(ctx) diff --git a/internal/cmd/context/set_test.go b/internal/cmd/context/set_test.go index ba9b493..96dad7e 100644 --- a/internal/cmd/context/set_test.go +++ b/internal/cmd/context/set_test.go @@ -309,3 +309,39 @@ func TestContextSet_APIKeyCommandClearsPlainKey(t *testing.T) { assert.Equal(t, "echo new-command-key", ctxE.APIKeyCommand) assert.Empty(t, ctxE.APIKey) } + +func TestContextSet_OAuthContext(t *testing.T) { + env := newEnv(t) + t.Cleanup(env.Cleanup) + + dir := t.TempDir() + cfgPath := testutil.WriteContextConfigFile(t, dir, "staging", map[string]map[string]string{ + "staging": {"api_key": "sk"}, + }) + + _, _, err := testutil.Exec(t, env, "--config", cfgPath, "context", "set", "dev", + "--endpoint", "grpc.development-cloud.qdrant.io:443", + "--account-id", "acc-1", + "--oauth", + ) + require.NoError(t, err) + + ctx := testutil.FindContextEntry(t, cfgPath, "dev") + require.NotNil(t, ctx) + assert.Equal(t, "oauth", ctx.Auth) + assert.Empty(t, ctx.APIKey) + assert.Empty(t, ctx.APIKeyCommand) +} + +func TestContextSet_OAuthAndAPIKeyAreExclusive(t *testing.T) { + env := newEnv(t) + t.Cleanup(env.Cleanup) + t.Setenv("HOME", t.TempDir()) + + _, _, err := testutil.Exec(t, env, "context", "set", "dev", + "--endpoint", "grpc.development-cloud.qdrant.io:443", + "--account-id", "acc-1", + "--oauth", "--api-key", "sk", + ) + require.Error(t, err) +} diff --git a/internal/cmd/context/show.go b/internal/cmd/context/show.go index 1efae58..b8f93c1 100644 --- a/internal/cmd/context/show.go +++ b/internal/cmd/context/show.go @@ -6,6 +6,7 @@ import ( "github.com/qdrant/qcloud-cli/internal/cmd/base" "github.com/qdrant/qcloud-cli/internal/cmd/output" "github.com/qdrant/qcloud-cli/internal/state" + "github.com/qdrant/qcloud-cli/internal/state/config" ) type showOutput struct { @@ -13,6 +14,7 @@ type showOutput struct { Endpoint string `json:"endpoint"` AccountID string `json:"account_id"` APIKeyCommand string `json:"api_key_command,omitempty"` + Auth string `json:"auth,omitempty"` } func newShowCommand(s *state.State) *cobra.Command { @@ -31,9 +33,10 @@ qcloud context show`, endpoint := s.Config.Endpoint() accountID := s.Config.AccountID() - var apiKeyCommand string + var apiKeyCommand, auth string if ctx, ok := s.Config.GetContext(activeCtx); ok { apiKeyCommand = ctx.APIKeyCommand + auth = ctx.Auth } if s.Config.JSONOutput() { @@ -42,6 +45,7 @@ qcloud context show`, Endpoint: endpoint, AccountID: accountID, APIKeyCommand: apiKeyCommand, + Auth: auth, }) } @@ -54,6 +58,10 @@ qcloud context show`, rows = append(rows, []string{"API Key Command", apiKeyCommand}) } + if auth == config.AuthOAuth { + rows = append(rows, []string{"Credential", "OAuth login (qcloud auth login)"}) + } + t := output.NewTable[[]string](cmd.OutOrStdout()) t.AddField("KEY", func(row []string) string { return row[0] }) t.AddField("VALUE", func(row []string) string { return row[1] }) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index afb5d11..ef4e5a3 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -26,9 +26,6 @@ type Token struct { Issuer string `yaml:"issuer,omitempty"` ClientID string `yaml:"client_id,omitempty"` Resource string `yaml:"resource,omitempty"` - // AccountID is the account the login was granted, when it was granted exactly one. Commands use it - // when no account is set by flag, environment or context. - AccountID string `yaml:"account_id,omitempty"` } // Expired reports whether the access token should be refreshed. diff --git a/internal/state/config/config.go b/internal/state/config/config.go index e748274..412da15 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -38,6 +38,39 @@ type ContextEntry struct { APIKey string `mapstructure:"api_key" yaml:"api_key,omitempty" json:"api_key,omitempty"` APIKeyCommand string `mapstructure:"api_key_command" yaml:"api_key_command,omitempty" json:"api_key_command,omitempty"` AccountID string `mapstructure:"account_id" yaml:"account_id,omitempty" json:"account_id,omitempty"` + // Auth is AuthOAuth for a context that uses the OAuth login for its endpoint ("qcloud auth login") + // instead of an API key. Empty for an API key context. + Auth string `mapstructure:"auth" yaml:"auth,omitempty" json:"auth,omitempty"` +} + +// AuthOAuth marks a context that authenticates with the OAuth login for its endpoint. +const AuthOAuth = "oauth" + +// IsOAuth reports whether the context uses the OAuth login instead of an API key. +func (e ContextEntry) IsOAuth() bool { + return e.Auth == AuthOAuth +} + +// Validate checks that the context has an endpoint, an account, and exactly one credential: an API key +// (or a command that prints one), or the OAuth login. +func (e ContextEntry) Validate() error { + if e.Endpoint == "" { + return errors.New("cannot set a context with an empty endpoint") + } + + hasKey := e.APIKey != "" || e.APIKeyCommand != "" + switch { + case hasKey && e.IsOAuth(): + return errors.New("a context uses either an API key or OAuth, not both") + case !hasKey && !e.IsOAuth(): + return errors.New("cannot set a context without a credential (use --api-key, --api-key-command, --api-key-helper, or --oauth)") + } + + if e.AccountID == "" { + return errors.New("cannot set a context with an empty account id") + } + + return nil } // File holds the top-level structure of the config file. @@ -203,6 +236,11 @@ func (c *Config) ActiveContext() string { return c.file.CurrentContext } +// Contexts returns a copy of all contexts, in file order. +func (c *Config) Contexts() []ContextEntry { + return append([]ContextEntry(nil), c.file.Contexts...) +} + // ContextNames returns a sorted list of all context names. func (c *Config) ContextNames() []string { names := make([]string, 0, len(c.file.Contexts)) diff --git a/internal/state/state.go b/internal/state/state.go index 2796b56..d3a8089 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -205,19 +205,12 @@ func (s *State) SetUpdater(u Updater) { s.updater = u } -// AccountID returns the configured account ID or an error. Without one from a flag, the environment -// or a context, it falls back to the account an OAuth login was granted, when it was granted exactly one. +// AccountID returns the configured account ID or an error. func (s *State) AccountID() (string, error) { - if id := s.Config.AccountID(); id != "" { - return id, nil + id := s.Config.AccountID() + if id == "" { + return "", errNoAccountID } - if s.Config.APIKey() == "" { - path := oauth.CredentialsPath(s.Config.ConfigFilePath()) - if tok, ok, err := oauth.LoadToken(path, s.Config.Endpoint()); err == nil && ok && tok.AccountID != "" { - return tok.AccountID, nil - } - } - - return "", errNoAccountID + return id, nil } From e9c84986e83c7b5cb2afcb6d479f843c8173b4fe Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 16:00:06 +0200 Subject: [PATCH 18/25] [CP-768] feat(context): no --oauth on context set; OAuth contexts come from login OAuth contexts are created and updated by qcloud auth login only; a wrong one is fixed by logging in again. context set now refuses to change a context the login manages, and only creates API key contexts. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/qcloud_context_set.md | 12 +++----- internal/cmd/context/set.go | 43 +++++++++------------------- internal/cmd/context/set_test.go | 42 +++++++++------------------ internal/state/config/config.go | 2 +- 4 files changed, 33 insertions(+), 66 deletions(-) diff --git a/docs/reference/qcloud_context_set.md b/docs/reference/qcloud_context_set.md index 65f08cc..3be0db6 100644 --- a/docs/reference/qcloud_context_set.md +++ b/docs/reference/qcloud_context_set.md @@ -6,11 +6,11 @@ Create or update a context Create or update a named context in the configuration file. -A context stores connection settings (endpoint, account ID) and one credential +A context stores connection settings (endpoint, account ID) and an API key under a name, so you can switch between environments with -"qcloud context use ". The credential is either an API key or, with ---oauth, the OAuth login for the endpoint ("qcloud auth login", which also -creates OAuth contexts for your accounts itself); never both, never none. +"qcloud context use ". Contexts that use your OAuth login instead are +created by "qcloud auth login", one per account you granted; this command does +not change those. There are three ways to provide the API key: @@ -51,9 +51,6 @@ qcloud context set staging --api-key-command 'op read op://vault/qdrant/api-key' # Use a named helper preset qcloud context set staging --api-key-helper 1password --api-key-ref op://vault/qdrant/api-key --account-id acc-123 - -# Use the OAuth login for the endpoint instead of an API key -qcloud context set dev --endpoint grpc.development-cloud.qdrant.io:443 --account-id acc-123 --oauth ``` ### Options @@ -66,7 +63,6 @@ qcloud context set dev --endpoint grpc.development-cloud.qdrant.io:443 --account --api-key-ref string Secret reference for the credential helper --endpoint string API endpoint for this context -h, --help help for set - --oauth Use the OAuth login for the endpoint (qcloud auth login) instead of an API key ``` ### Options inherited from parent commands diff --git a/internal/cmd/context/set.go b/internal/cmd/context/set.go index c0d4649..a530694 100644 --- a/internal/cmd/context/set.go +++ b/internal/cmd/context/set.go @@ -11,7 +11,6 @@ import ( "github.com/qdrant/qcloud-cli/internal/cmd/base" "github.com/qdrant/qcloud-cli/internal/cmd/util" "github.com/qdrant/qcloud-cli/internal/state" - "github.com/qdrant/qcloud-cli/internal/state/config" ) // apiKeyHelpers maps --api-key-helper names to command templates. @@ -35,21 +34,18 @@ qcloud context set staging --api-key sk-... --account-id acc-123 qcloud context set staging --api-key-command 'op read op://vault/qdrant/api-key' --account-id acc-123 # Use a named helper preset -qcloud context set staging --api-key-helper 1password --api-key-ref op://vault/qdrant/api-key --account-id acc-123 - -# Use the OAuth login for the endpoint instead of an API key -qcloud context set dev --endpoint grpc.development-cloud.qdrant.io:443 --account-id acc-123 --oauth`, +qcloud context set staging --api-key-helper 1password --api-key-ref op://vault/qdrant/api-key --account-id acc-123`, BaseCobraCommand: func() *cobra.Command { cmd := &cobra.Command{ Use: "set ", Short: "Create or update a context", Long: `Create or update a named context in the configuration file. -A context stores connection settings (endpoint, account ID) and one credential +A context stores connection settings (endpoint, account ID) and an API key under a name, so you can switch between environments with -"qcloud context use ". The credential is either an API key or, with ---oauth, the OAuth login for the endpoint ("qcloud auth login", which also -creates OAuth contexts for your accounts itself); never both, never none. +"qcloud context use ". Contexts that use your OAuth login instead are +created by "qcloud auth login", one per account you granted; this command does +not change those. There are three ways to provide the API key: @@ -88,14 +84,10 @@ still take precedence over the command at runtime.`, }) cmd.Flags().String("api-key-ref", "", "Secret reference for the credential helper") cmd.Flags().String("account-id", "", "Account ID for this context") - cmd.Flags().Bool("oauth", false, "Use the OAuth login for the endpoint (qcloud auth login) instead of an API key") cmd.MarkFlagsRequiredTogether("api-key-helper", "api-key-ref") cmd.MarkFlagsMutuallyExclusive("api-key", "api-key-command") cmd.MarkFlagsMutuallyExclusive("api-key", "api-key-helper") cmd.MarkFlagsMutuallyExclusive("api-key-command", "api-key-helper") - cmd.MarkFlagsMutuallyExclusive("oauth", "api-key") - cmd.MarkFlagsMutuallyExclusive("oauth", "api-key-command") - cmd.MarkFlagsMutuallyExclusive("oauth", "api-key-helper") return cmd }, Run: func(s *state.State, cmd *cobra.Command, args []string) error { @@ -103,6 +95,9 @@ still take precedence over the command at runtime.`, ctx, _ := s.Config.GetContext(name) ctx.Name = name + if ctx.IsOAuth() { + return fmt.Errorf("context %q is managed by \"qcloud auth login\"; log in again to update it, or delete it with \"qcloud context delete %s\"", name, name) + } if ep, ok := flagChangedWithValue(cmd, "endpoint"); ok { ctx.Endpoint = ep @@ -115,24 +110,14 @@ still take precedence over the command at runtime.`, return err } - useOAuth, _ := cmd.Flags().GetBool("oauth") - switch { - case useOAuth: - ctx.Auth = config.AuthOAuth - ctx.APIKey = "" - ctx.APIKeyCommand = "" - case apiKeyCommand != "": + if apiKeyCommand != "" { ctx.APIKeyCommand = apiKeyCommand ctx.APIKey = "" - ctx.Auth = "" - default: - if key, ok := flagChangedWithValue(cmd, "api-key"); ok { - ctx.APIKey = key - ctx.APIKeyCommand = "" - ctx.Auth = "" - } else if !ctx.IsOAuth() && ctx.APIKey == "" && ctx.APIKeyCommand == "" { - ctx.APIKey = s.Config.APIKey() - } + } else if key, ok := flagChangedWithValue(cmd, "api-key"); ok { + ctx.APIKey = key + ctx.APIKeyCommand = "" + } else if ctx.APIKey == "" && ctx.APIKeyCommand == "" { + ctx.APIKey = s.Config.APIKey() } if id, ok := flagChangedWithValue(cmd, "account-id"); ok { diff --git a/internal/cmd/context/set_test.go b/internal/cmd/context/set_test.go index 96dad7e..c0032e4 100644 --- a/internal/cmd/context/set_test.go +++ b/internal/cmd/context/set_test.go @@ -1,6 +1,7 @@ package context_test import ( + "os" "path" "testing" @@ -310,38 +311,23 @@ func TestContextSet_APIKeyCommandClearsPlainKey(t *testing.T) { assert.Empty(t, ctxE.APIKey) } -func TestContextSet_OAuthContext(t *testing.T) { +func TestContextSet_RefusesAContextManagedByLogin(t *testing.T) { env := newEnv(t) t.Cleanup(env.Cleanup) - dir := t.TempDir() - cfgPath := testutil.WriteContextConfigFile(t, dir, "staging", map[string]map[string]string{ - "staging": {"api_key": "sk"}, - }) + cfgPath := path.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(cfgPath, []byte(`contexts: + - name: dev-my-account + endpoint: grpc.development-cloud.qdrant.io:443 + account_id: acc-1 + auth: oauth +`), 0o600)) - _, _, err := testutil.Exec(t, env, "--config", cfgPath, "context", "set", "dev", - "--endpoint", "grpc.development-cloud.qdrant.io:443", - "--account-id", "acc-1", - "--oauth", - ) - require.NoError(t, err) + _, _, err := testutil.Exec(t, env, "--config", cfgPath, "context", "set", "dev-my-account", "--account-id", "acc-2") + require.Error(t, err) + assert.Contains(t, err.Error(), "managed by \"qcloud auth login\"") - ctx := testutil.FindContextEntry(t, cfgPath, "dev") + ctx := testutil.FindContextEntry(t, cfgPath, "dev-my-account") require.NotNil(t, ctx) - assert.Equal(t, "oauth", ctx.Auth) - assert.Empty(t, ctx.APIKey) - assert.Empty(t, ctx.APIKeyCommand) -} - -func TestContextSet_OAuthAndAPIKeyAreExclusive(t *testing.T) { - env := newEnv(t) - t.Cleanup(env.Cleanup) - t.Setenv("HOME", t.TempDir()) - - _, _, err := testutil.Exec(t, env, "context", "set", "dev", - "--endpoint", "grpc.development-cloud.qdrant.io:443", - "--account-id", "acc-1", - "--oauth", "--api-key", "sk", - ) - require.Error(t, err) + assert.Equal(t, "acc-1", ctx.AccountID, "unchanged") } diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 412da15..a2e575d 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -63,7 +63,7 @@ func (e ContextEntry) Validate() error { case hasKey && e.IsOAuth(): return errors.New("a context uses either an API key or OAuth, not both") case !hasKey && !e.IsOAuth(): - return errors.New("cannot set a context without a credential (use --api-key, --api-key-command, --api-key-helper, or --oauth)") + return errors.New("cannot set a context with an empty API key (use --api-key, --api-key-command, or --api-key-helper; for OAuth, run \"qcloud auth login\")") } if e.AccountID == "" { From a52352f937521fe5582dbdd588b1f6872ff60d01 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 16:11:16 +0200 Subject: [PATCH 19/25] [CP-768] feat(context): context delete refuses OAuth contexts They follow the login: auth login creates and updates them, auth logout removes them. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/context/delete.go | 9 ++++++++- internal/cmd/context/delete_test.go | 20 ++++++++++++++++++++ internal/cmd/context/set.go | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/cmd/context/delete.go b/internal/cmd/context/delete.go index 459994a..d523ca1 100644 --- a/internal/cmd/context/delete.go +++ b/internal/cmd/context/delete.go @@ -29,10 +29,17 @@ qcloud context delete staging --force`, Run: func(s *state.State, cmd *cobra.Command, args []string) error { name := args[0] - if _, ok := s.Config.GetContext(name); !ok { + ctx, ok := s.Config.GetContext(name) + if !ok { return fmt.Errorf("context %q not found", name) } + // OAuth contexts follow the login: "qcloud auth login" creates and updates them, "qcloud auth + // logout" removes them. + if ctx.IsOAuth() { + return fmt.Errorf("context %q belongs to your OAuth login; run \"qcloud auth logout --endpoint %s\" to remove it", name, ctx.Endpoint) + } + force, _ := cmd.Flags().GetBool("force") if !util.ConfirmAction(force, cmd.ErrOrStderr(), fmt.Sprintf("Are you sure you want to delete context %q?", name)) { fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") diff --git a/internal/cmd/context/delete_test.go b/internal/cmd/context/delete_test.go index 28b6491..1b8950b 100644 --- a/internal/cmd/context/delete_test.go +++ b/internal/cmd/context/delete_test.go @@ -1,6 +1,8 @@ package context_test import ( + "os" + "path" "testing" "github.com/stretchr/testify/assert" @@ -71,3 +73,21 @@ func TestContextDelete_RequiresExactlyOneArg(t *testing.T) { _, _, err := testutil.Exec(t, env, "--config", cfgPath, "context", "delete", "--force") require.Error(t, err) } + +func TestContextDelete_RefusesAnOAuthContext(t *testing.T) { + env := newEnv(t) + t.Cleanup(env.Cleanup) + + cfgPath := path.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(cfgPath, []byte(`contexts: + - name: dev-my-account + endpoint: grpc.development-cloud.qdrant.io:443 + account_id: acc-1 + auth: oauth +`), 0o600)) + + _, _, err := testutil.Exec(t, env, "--config", cfgPath, "context", "delete", "dev-my-account", "--force") + require.Error(t, err) + assert.Contains(t, err.Error(), "qcloud auth logout") + assert.NotNil(t, testutil.FindContextEntry(t, cfgPath, "dev-my-account"), "still there") +} diff --git a/internal/cmd/context/set.go b/internal/cmd/context/set.go index a530694..aea50c1 100644 --- a/internal/cmd/context/set.go +++ b/internal/cmd/context/set.go @@ -96,7 +96,7 @@ still take precedence over the command at runtime.`, ctx, _ := s.Config.GetContext(name) ctx.Name = name if ctx.IsOAuth() { - return fmt.Errorf("context %q is managed by \"qcloud auth login\"; log in again to update it, or delete it with \"qcloud context delete %s\"", name, name) + return fmt.Errorf("context %q is managed by \"qcloud auth login\"; log in again to update it, or log out to remove it", name) } if ep, ok := flagChangedWithValue(cmd, "endpoint"); ok { From d4347c76953c70dffa6d9ab5e1cbfbab83cfb141 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 17:00:45 +0200 Subject: [PATCH 20/25] [CP-768] feat(auth): with several accounts, the first context becomes current As with one account, unless a context is already current. The login marks the current context with * in its list. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/qcloud_auth_login.md | 6 +++--- internal/cmd/auth/auth_test.go | 18 +++++++++++------- internal/cmd/auth/contexts.go | 4 ++-- internal/cmd/auth/login.go | 19 ++++++++++++------- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md index 0f335d3..2dd742b 100644 --- a/docs/reference/qcloud_auth_login.md +++ b/docs/reference/qcloud_auth_login.md @@ -24,9 +24,9 @@ Tokens are stored in credentials.yaml next to the config file (mode 0600). After the login, qcloud creates a context for every account you granted on the consent page (named after the account, prefixed with the environment, e.g. -dev-my-account), marked as OAuth so it needs no API key. With one account its -context becomes the current one, unless you already have a current context; -with several, pick one with "qcloud context use ". A later login updates +dev-my-account), marked as OAuth so it needs no API key. Unless you already have +a current context, the first one becomes current; switch with +"qcloud context use ". A later login updates these contexts, and "qcloud auth logout" removes them. Contexts with an API key are never changed. diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index 132bf15..ce66312 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -206,7 +206,7 @@ func TestAuthLogin_ShowsTheGrantedScope(t *testing.T) { assert.NotContains(t, statusOut, "Requested") } -func TestAuthLogin_SeveralAccountsGetAContextEachAndNoneIsSelected(t *testing.T) { +func TestAuthLogin_SeveralAccountsGetAContextEachAndTheFirstIsSelected(t *testing.T) { env := testutil.NewBareTestEnv(t) prepareLogin(t, env) grantAccounts(env, @@ -218,13 +218,17 @@ func TestAuthLogin_SeveralAccountsGetAContextEachAndNoneIsSelected(t *testing.T) stdout, _, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) assert.Contains(t, stdout, "Contexts for your 2 accounts:") - assert.Contains(t, stdout, "dev-robert-stam-base-account") - assert.Contains(t, stdout, "dev-andrey-vasnetsov-base-account") - assert.Contains(t, stdout, "Select one with: qcloud context use ") + assert.Contains(t, stdout, " * dev-robert-stam-base-account (Robert Stam - Base Account)") + assert.Contains(t, stdout, " dev-andrey-vasnetsov-base-account (Andrey Vasnetsov - Base Account)") + assert.Contains(t, stdout, "Switch with: qcloud context use ") - _, _, err = testutil.Exec(t, env, "cluster", "list", "--config", cfg, "--endpoint", devEndpoint) - require.Error(t, err) - assert.Contains(t, err.Error(), "no account ID configured") + // The first account's context is current. + env.Server.ListClustersCalls.Returns(&clusterv1.ListClustersResponse{}, nil) + _, _, err = testutil.Exec(t, env, "cluster", "list", "--config", cfg) + require.NoError(t, err) + req, ok := env.Server.ListClustersCalls.Last() + require.True(t, ok) + assert.Equal(t, "a1", req.GetAccountId()) } func TestAuthLogin_UpdatesOnlyItsOwnContexts(t *testing.T) { diff --git a/internal/cmd/auth/contexts.go b/internal/cmd/auth/contexts.go index f6a3852..49f0725 100644 --- a/internal/cmd/auth/contexts.go +++ b/internal/cmd/auth/contexts.go @@ -56,7 +56,7 @@ func contextPrefix(endpoint string) string { // syncOAuthContexts keeps one OAuth context per granted account for endpoint: it creates a context for // a new account, keeps the name of an existing one, and removes the OAuth contexts of accounts that are // no longer granted. Contexts with an API key, and OAuth contexts of other endpoints, are never touched. -// With exactly one account, its context becomes the current one unless another context already is. +// When no context is current, the first account's context becomes the current one. // It returns the context names in account order, and the name it made current, if any. func syncOAuthContexts(s *state.State, endpoint string, accounts []*accountv1.Account) ([]string, string, error) { existingByAccount := map[string]string{} @@ -95,7 +95,7 @@ func syncOAuthContexts(s *state.State, endpoint string, accounts []*accountv1.Ac } selected := "" - if len(names) == 1 && s.Config.CurrentContext() == "" { + if len(names) > 0 && s.Config.CurrentContext() == "" { s.Config.SetCurrentContext(names[0]) selected = names[0] } diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 87651b2..0a02684 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -37,9 +37,9 @@ Tokens are stored in credentials.yaml next to the config file (mode 0600). After the login, qcloud creates a context for every account you granted on the consent page (named after the account, prefixed with the environment, e.g. -dev-my-account), marked as OAuth so it needs no API key. With one account its -context becomes the current one, unless you already have a current context; -with several, pick one with "qcloud context use ". A later login updates +dev-my-account), marked as OAuth so it needs no API key. Unless you already have +a current context, the first one becomes current; switch with +"qcloud context use ". A later login updates these contexts, and "qcloud auth logout" removes them. Contexts with an API key are never changed.`, Example: `# Browser login (default production endpoint) @@ -115,7 +115,7 @@ qcloud auth login --device`, return fmt.Errorf("logged in, but could not write the contexts: %w", err) } - printLoginResult(cmd, who, access, accounts, names, selected) + printLoginResult(cmd, who, access, accounts, names, selected, s.Config.CurrentContext()) return nil }, @@ -151,7 +151,7 @@ func grantedAccounts(cmd *cobra.Command, s *state.State) ([]*accountv1.Account, } // printLoginResult reports the login and the contexts it created, one line per context. -func printLoginResult(cmd *cobra.Command, who, access string, accounts []*accountv1.Account, names []string, selected string) { +func printLoginResult(cmd *cobra.Command, who, access string, accounts []*accountv1.Account, names []string, selected, current string) { out := cmd.OutOrStdout() switch len(names) { case 0: @@ -164,9 +164,14 @@ func printLoginResult(cmd *cobra.Command, who, access string, accounts []*accoun default: fmt.Fprintf(out, "%s%s. Contexts for your %d accounts:\n", who, access, len(names)) for i, name := range names { - fmt.Fprintf(out, " %s (%s)\n", name, accounts[i].GetName()) + marker := " " + if name == current { + marker = "*" + } + + fmt.Fprintf(out, " %s %s (%s)\n", marker, name, accounts[i].GetName()) } - fmt.Fprintln(out, "Select one with: qcloud context use ") + fmt.Fprintln(out, "Switch with: qcloud context use ") } } From d108d55c1f4ac9db4110a1de4bb9ac10d99eb491 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Thu, 22 Oct 2026 19:24:21 +0200 Subject: [PATCH 21/25] [CP-768] feat(auth): hint to log in with manage on an insufficient-scope error When the gateway refuses a call only because the OAuth access is read only (ErrorInfo INSUFFICIENT_SCOPE, qdrant-cloud-api-gateway#694), qcloud adds: Run "qcloud auth login --scope manage" and allow manage on the consent page. Role denials get no hint. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/qcloud/main.go | 4 ++++ go.mod | 2 +- internal/cli/hints.go | 34 ++++++++++++++++++++++++++++++++++ internal/cli/hints_test.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 internal/cli/hints.go create mode 100644 internal/cli/hints_test.go diff --git a/cmd/qcloud/main.go b/cmd/qcloud/main.go index 0852cca..0ea64c2 100644 --- a/cmd/qcloud/main.go +++ b/cmd/qcloud/main.go @@ -29,6 +29,10 @@ func main() { cmd.SetContext(ctx) if err := cmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) + if hint := cli.ErrorHint(err); hint != "" { + fmt.Fprintln(os.Stderr, hint) + } + os.Exit(1) } } diff --git a/go.mod b/go.mod index 38a70a9..47e2f4f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260713224248-f5fc221cf8c4 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 @@ -55,5 +56,4 @@ require ( golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260713224248-f5fc221cf8c4 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260713224248-f5fc221cf8c4 // indirect ) diff --git a/internal/cli/hints.go b/internal/cli/hints.go new file mode 100644 index 0000000..ac2a2c3 --- /dev/null +++ b/internal/cli/hints.go @@ -0,0 +1,34 @@ +package cli + +import ( + "errors" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/status" +) + +// insufficientScopeReason is the ErrorInfo reason the gateway sets when only the OAuth scope stands in +// the way: the user's role allows the call, the access granted to the CLI does not. +const insufficientScopeReason = "INSUFFICIENT_SCOPE" + +// ErrorHint returns a line telling the user how to fix err, or "" when there is nothing to add. +func ErrorHint(err error) string { + var grpcErr interface{ GRPCStatus() *status.Status } + if !errors.As(err, &grpcErr) { + return "" + } + + for _, detail := range grpcErr.GRPCStatus().Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if ok && info.GetReason() == insufficientScopeReason { + scope := info.GetMetadata()["required_scope"] + if scope == "" { + scope = "manage" + } + + return "Your login only has read-only access. Run \"qcloud auth login --scope " + scope + "\" and allow " + scope + " on the consent page." + } + } + + return "" +} diff --git a/internal/cli/hints_test.go b/internal/cli/hints_test.go new file mode 100644 index 0000000..d40296a --- /dev/null +++ b/internal/cli/hints_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestErrorHint(t *testing.T) { + t.Parallel() + + st, err := status.New(codes.PermissionDenied, "read only").WithDetails(&errdetails.ErrorInfo{ + Reason: "INSUFFICIENT_SCOPE", + Metadata: map[string]string{"required_scope": "manage"}, + }) + require.NoError(t, err) + + // Commands wrap the gRPC error. + wrapped := fmt.Errorf("failed to create backup: %w", st.Err()) + assert.Equal(t, `Your login only has read-only access. Run "qcloud auth login --scope manage" and allow manage on the consent page.`, ErrorHint(wrapped)) + + assert.Empty(t, ErrorHint(status.Error(codes.PermissionDenied, "permission denied - not granted")), "a role denial has no hint") + assert.Empty(t, ErrorHint(fmt.Errorf("plain error"))) +} From c6998884770932b3aed36a5f2c318676d887fd50 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Fri, 23 Oct 2026 07:36:24 +0200 Subject: [PATCH 22/25] [CP-768] feat(auth): keep the OAuth tokens in the OS keychain The access and refresh tokens go to the OS keychain (macOS Keychain, Windows Credential Manager, Secret Service on Linux) via go-keyring, as two entries per endpoint. credentials.yaml keeps only what is not secret, with store: keychain. - Without a keychain (headless Linux, containers, CI) the tokens fall back to credentials.yaml (0600) and login warns. - QDRANT_CLOUD_TOKEN_STORE=keychain requires the keychain; =file skips it. - A session stored in the file moves to the keychain on its next refresh. - Logout removes the keychain entries; auth status does not read them. - Test binaries use an in-memory store, never the machine's keychain. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/qcloud_auth_login.md | 6 +- go.mod | 4 + go.sum | 12 ++ internal/cmd/auth/auth_test.go | 6 + internal/cmd/auth/login.go | 13 +- internal/cmd/auth/status.go | 4 +- internal/cmd/auth/token.go | 2 +- internal/oauth/secrets.go | 178 +++++++++++++++++++++++++ internal/oauth/store.go | 134 +++++++++++++++---- internal/oauth/store_test.go | 194 ++++++++++++++++++++++++++-- internal/state/state.go | 2 +- 11 files changed, 516 insertions(+), 39 deletions(-) create mode 100644 internal/oauth/secrets.go diff --git a/docs/reference/qcloud_auth_login.md b/docs/reference/qcloud_auth_login.md index 2dd742b..a568812 100644 --- a/docs/reference/qcloud_auth_login.md +++ b/docs/reference/qcloud_auth_login.md @@ -20,7 +20,11 @@ to login.cloud.qdrant.io. Override with --login-url only for debugging. The Qdrant Cloud CLI client id is built in per login URL. --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID override it, for a tenant without a built-in id. -Tokens are stored in credentials.yaml next to the config file (mode 0600). +The access and refresh tokens are stored in the OS keychain (macOS Keychain, +Windows Credential Manager, or the Secret Service on Linux); the rest of the +session is in credentials.yaml next to the config file. Without a keychain, the +tokens go to credentials.yaml (mode 0600) too. Set QDRANT_CLOUD_TOKEN_STORE to +"keychain" to require the keychain, or to "file" to skip it. After the login, qcloud creates a context for every account you granted on the consent page (named after the account, prefixed with the environment, e.g. diff --git a/go.mod b/go.mod index 47e2f4f..552b2e9 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 + github.com/zalando/go-keyring v0.2.6 google.golang.org/genproto/googleapis/rpc v0.0.0-20260713224248-f5fc221cf8c4 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 @@ -22,15 +23,18 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect code.gitea.io/sdk/gitea v0.23.2 // indirect github.com/42wim/httpsig v1.2.4 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/go-github/v86 v86.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/go.sum b/go.sum index d65a727..c78858b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= @@ -10,6 +12,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ8HIoxnBmPJbN0sw= github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= @@ -24,6 +28,8 @@ github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -33,6 +39,8 @@ github.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnD github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= @@ -91,12 +99,16 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index ce66312..0c6f332 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -144,6 +144,12 @@ func TestAuthLogin_StoresToken(t *testing.T) { require.NoError(t, err) assert.Contains(t, stdout, "Logged in; no accounts were granted.") + // The tokens are in the keychain (in-memory in tests), not in credentials.yaml. + creds, err := os.ReadFile(filepath.Join(filepath.Dir(cfg), "credentials.yaml")) + require.NoError(t, err) + assert.Contains(t, string(creds), "store: keychain") + assert.NotContains(t, string(creds), "access-from-login") + tokenOut, _, err := testutil.Exec(t, env, "auth", "token", "--config", cfg, "--endpoint", devEndpoint) require.NoError(t, err) assert.Equal(t, "access-from-login\n", tokenOut) diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 0a02684..7abaa71 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -33,7 +33,11 @@ to login.cloud.qdrant.io. Override with --login-url only for debugging. The Qdrant Cloud CLI client id is built in per login URL. --client-id or QDRANT_CLOUD_OAUTH_CLIENT_ID override it, for a tenant without a built-in id. -Tokens are stored in credentials.yaml next to the config file (mode 0600). +The access and refresh tokens are stored in the OS keychain (macOS Keychain, +Windows Credential Manager, or the Secret Service on Linux); the rest of the +session is in credentials.yaml next to the config file. Without a keychain, the +tokens go to credentials.yaml (mode 0600) too. Set QDRANT_CLOUD_TOKEN_STORE to +"keychain" to require the keychain, or to "file" to skip it. After the login, qcloud creates a context for every account you granted on the consent page (named after the account, prefixed with the environment, e.g. @@ -89,10 +93,15 @@ qcloud auth login --device`, tok.GrantedScope = claims.GrantedScope path := oauth.CredentialsPath(s.Config.ConfigFilePath()) - if err := oauth.SaveToken(path, tok); err != nil { + saved, err := oauth.SaveToken(path, tok) + if err != nil { return err } + if saved.KeychainErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "No OS keychain available (%v); the tokens are stored in %s (0600).\n", saved.KeychainErr, path) + } + who := "Logged in" if claims.Email != "" { who += " as " + claims.Email diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go index dbddfda..4d535c6 100644 --- a/internal/cmd/auth/status.go +++ b/internal/cmd/auth/status.go @@ -44,7 +44,7 @@ access token.`, } path := oauth.CredentialsPath(s.Config.ConfigFilePath()) - tok, ok, err := oauth.LoadToken(path, s.Config.Endpoint()) + tok, ok, err := oauth.LoadTokenInfo(path, s.Config.Endpoint()) if err != nil { return err } @@ -61,7 +61,7 @@ access token.`, fmt.Fprintf(out, "Status: %s\n", status) // The access the user granted on the consent page. The requested scope (what qcloud asked for) - // is in credentials.yaml; showing it here next to this only confuses. + // is stored too; showing it here next to this only confuses. if tok.GrantedScope != "" { fmt.Fprintf(out, "Access: %s\n", oauth.ScopeLabel(tok.GrantedScope)) } diff --git a/internal/cmd/auth/token.go b/internal/cmd/auth/token.go index e153874..eeab90f 100644 --- a/internal/cmd/auth/token.go +++ b/internal/cmd/auth/token.go @@ -44,7 +44,7 @@ qcloud auth token --json`, return fmt.Errorf("refresh OAuth token: %w", err) } - if err := oauth.SaveToken(path, refreshed); err != nil { + if _, err := oauth.SaveToken(path, refreshed); err != nil { return err } diff --git a/internal/oauth/secrets.go b/internal/oauth/secrets.go new file mode 100644 index 0000000..f1b270f --- /dev/null +++ b/internal/oauth/secrets.go @@ -0,0 +1,178 @@ +package oauth + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/zalando/go-keyring" +) + +// TokenStoreEnv selects where the access and refresh tokens are kept: +// - "" or "auto": the OS keychain, or credentials.yaml (0600) when no keychain is available; +// - "keychain": the OS keychain only; saving fails when it is not available; +// - "file": credentials.yaml only. +const TokenStoreEnv = "QDRANT_CLOUD_TOKEN_STORE" + +// Token store modes and the value of Token.Store. +const ( + StoreAuto = "auto" + StoreKeychain = "keychain" + StoreFile = "file" +) + +// keychainService is the service name of qcloud's entries in the OS keychain. +const keychainService = "qcloud" + +// secretStore is the OS keychain: macOS Keychain, Windows Credential Manager, or the Secret Service +// (GNOME Keyring, KWallet) on Linux. +type secretStore interface { + Set(service, user, secret string) error + Get(service, user string) (string, error) + Delete(service, user string) error +} + +type osKeychain struct{} + +func (osKeychain) Set(service, user, secret string) error { return keyring.Set(service, user, secret) } +func (osKeychain) Get(service, user string) (string, error) { return keyring.Get(service, user) } +func (osKeychain) Delete(service, user string) error { return keyring.Delete(service, user) } + +// defaultSecrets is the OS keychain, except in test binaries: tests must never read or write the +// keychain of the machine they run on. +var defaultSecrets = func() secretStore { + if testing.Testing() { + return newMemorySecrets() + } + + return osKeychain{} +}() + +// memorySecrets is an in-memory secretStore for tests. +type memorySecrets struct { + mu sync.Mutex + secrets map[string]string +} + +func newMemorySecrets() *memorySecrets { + return &memorySecrets{secrets: map[string]string{}} +} + +func (m *memorySecrets) Set(service, user, secret string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.secrets[service+"\x00"+user] = secret + + return nil +} + +func (m *memorySecrets) Get(service, user string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + secret, ok := m.secrets[service+"\x00"+user] + if !ok { + return "", keyring.ErrNotFound + } + + return secret, nil +} + +func (m *memorySecrets) Delete(service, user string) error { + m.mu.Lock() + defer m.mu.Unlock() + key := service + "\x00" + user + if _, ok := m.secrets[key]; !ok { + return keyring.ErrNotFound + } + + delete(m.secrets, key) + + return nil +} + +// storeMode reads TokenStoreEnv. +func storeMode() (string, error) { + switch mode := os.Getenv(TokenStoreEnv); mode { + case "", StoreAuto: + return StoreAuto, nil + case StoreKeychain, StoreFile: + return mode, nil + default: + return "", fmt.Errorf("%s=%q: use auto, keychain, or file", TokenStoreEnv, mode) + } +} + +// keychainUser names a token's keychain entry: the endpoint, the kind of token ("access" or +// "refresh"), and a short hash of the credentials file, so that two config directories keep separate +// sessions. +func keychainUser(path, endpoint, kind string) string { + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + + sum := sha256.Sum256([]byte(path)) + + return fmt.Sprintf("%s %s %s", endpoint, kind, hex.EncodeToString(sum[:4])) +} + +// setSecrets stores the access and refresh tokens in the keychain, as two entries: Windows limits an +// entry to 2.5 KB, which a JWT and a refresh token together can exceed. +func setSecrets(secrets secretStore, path string, tok Token) error { + if err := secrets.Set(keychainService, keychainUser(path, tok.Endpoint, "access"), tok.AccessToken); err != nil { + return err + } + + if tok.RefreshToken == "" { + return deleteSecret(secrets, path, tok.Endpoint, "refresh") + } + + return secrets.Set(keychainService, keychainUser(path, tok.Endpoint, "refresh"), tok.RefreshToken) +} + +// getSecrets fills in the access and refresh tokens of a token kept in the keychain. +func getSecrets(secrets secretStore, path string, tok Token) (Token, error) { + access, err := secrets.Get(keychainService, keychainUser(path, tok.Endpoint, "access")) + if err != nil { + return Token{}, keychainReadError(tok.Endpoint, err) + } + + refresh, err := secrets.Get(keychainService, keychainUser(path, tok.Endpoint, "refresh")) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return Token{}, keychainReadError(tok.Endpoint, err) + } + + tok.AccessToken = access + tok.RefreshToken = refresh + + return tok, nil +} + +func keychainReadError(endpoint string, err error) error { + if errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("the OAuth tokens for %s are missing from the OS keychain (run \"qcloud auth login\")", endpoint) + } + + return fmt.Errorf("reading the OAuth tokens for %s from the OS keychain: %w", endpoint, err) +} + +// deleteSecrets removes both keychain entries of endpoint; entries that do not exist are fine. +func deleteSecrets(secrets secretStore, path, endpoint string) error { + return errors.Join( + deleteSecret(secrets, path, endpoint, "access"), + deleteSecret(secrets, path, endpoint, "refresh"), + ) +} + +func deleteSecret(secrets secretStore, path, endpoint, kind string) error { + err := secrets.Delete(keychainService, keychainUser(path, endpoint, kind)) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return err + } + + return nil +} diff --git a/internal/oauth/store.go b/internal/oauth/store.go index ef4e5a3..2cf3d59 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -13,8 +13,11 @@ const credentialsFileName = "credentials.yaml" // Token is a stored OAuth session for one gRPC endpoint. type Token struct { - Endpoint string `yaml:"endpoint"` - AccessToken string `yaml:"access_token"` + Endpoint string `yaml:"endpoint"` + // Store is "keychain" when the access and refresh tokens are in the OS keychain; credentials.yaml + // then holds only the fields that are not secret. Empty means both are in credentials.yaml. + Store string `yaml:"store,omitempty"` + AccessToken string `yaml:"access_token,omitempty"` RefreshToken string `yaml:"refresh_token,omitempty"` TokenType string `yaml:"token_type,omitempty"` Expiry time.Time `yaml:"expiry,omitempty"` @@ -53,56 +56,110 @@ func CredentialsPath(configFilePath string) string { return filepath.Join(dir, credentialsFileName) } -// LoadToken returns the stored token for endpoint, or false if none. +// LoadToken returns the stored token for endpoint, with its access and refresh tokens, or false if +// none. func LoadToken(path, endpoint string) (Token, bool, error) { + return loadToken(defaultSecrets, path, endpoint) +} + +// LoadTokenInfo returns the stored token for endpoint like LoadToken, but without reading the OS +// keychain: when the tokens are kept there, AccessToken and RefreshToken are empty. +func LoadTokenInfo(path, endpoint string) (Token, bool, error) { file, err := readCredentials(path) if err != nil { return Token{}, false, err } - for _, tok := range file.Tokens { - if tok.Endpoint == endpoint { - return tok, true, nil - } + tok, ok := file.find(endpoint) + + return tok, ok, nil +} + +func loadToken(secrets secretStore, path, endpoint string) (Token, bool, error) { + tok, ok, err := LoadTokenInfo(path, endpoint) + if err != nil || !ok || tok.Store != StoreKeychain { + return tok, ok, err } - return Token{}, false, nil + tok, err = getSecrets(secrets, path, tok) + if err != nil { + return Token{}, false, err + } + + return tok, true, nil +} + +// Saved says where SaveToken put the access and refresh tokens. +type Saved struct { + // Store is StoreKeychain or StoreFile. + Store string + // KeychainErr is why the tokens went to credentials.yaml although the keychain was to be tried. + KeychainErr error +} + +// SaveToken upserts a token for its endpoint. The access and refresh tokens go to the OS keychain and +// the rest to credentials.yaml; see TokenStoreEnv. A token that was in credentials.yaml moves to the +// keychain on its next save. +func SaveToken(path string, tok Token) (Saved, error) { + mode, err := storeMode() + if err != nil { + return Saved{}, err + } + + return saveToken(defaultSecrets, mode, path, tok) } -// SaveToken upserts a token for its endpoint. -func SaveToken(path string, tok Token) error { +func saveToken(secrets secretStore, mode, path string, tok Token) (Saved, error) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("creating credentials dir: %w", err) + return Saved{}, fmt.Errorf("creating credentials dir: %w", err) } file, err := readCredentials(path) if err != nil { - return err + return Saved{}, err } - replaced := false - for i, existing := range file.Tokens { - if existing.Endpoint == tok.Endpoint { - file.Tokens[i] = tok - replaced = true - break + previous, _ := file.find(tok.Endpoint) + saved := Saved{Store: StoreFile} + tok.Store = "" + + if mode != StoreFile { + err := setSecrets(secrets, path, tok) + switch { + case err == nil: + saved.Store = StoreKeychain + tok.Store = StoreKeychain + tok.AccessToken = "" + tok.RefreshToken = "" + case mode == StoreKeychain: + return Saved{}, fmt.Errorf("storing the OAuth tokens in the OS keychain: %w (set %s=file to use credentials.yaml)", err, TokenStoreEnv) + default: + saved.KeychainErr = err } } - if !replaced { - file.Tokens = append(file.Tokens, tok) + // Tokens that move to the file must not stay behind in the keychain. + if saved.Store == StoreFile && previous.Store == StoreKeychain { + _ = deleteSecrets(secrets, path, tok.Endpoint) } - return writeCredentials(path, file) + file.upsert(tok) + + return saved, writeCredentials(path, file) } -// DeleteToken removes the stored token for endpoint. +// DeleteToken removes the stored token for endpoint, from credentials.yaml and the OS keychain. func DeleteToken(path, endpoint string) error { + return deleteToken(defaultSecrets, path, endpoint) +} + +func deleteToken(secrets secretStore, path, endpoint string) error { file, err := readCredentials(path) if err != nil { return err } + previous, _ := file.find(endpoint) filtered := file.Tokens[:0] for _, tok := range file.Tokens { if tok.Endpoint != endpoint { @@ -111,7 +168,38 @@ func DeleteToken(path, endpoint string) error { } file.Tokens = filtered - return writeCredentials(path, file) + if err := writeCredentials(path, file); err != nil { + return err + } + + // Without a keychain entry there may be no keychain at all (headless Linux, CI); only a failure to + // remove tokens that are known to be there is an error. + if err := deleteSecrets(secrets, path, endpoint); err != nil && previous.Store == StoreKeychain { + return fmt.Errorf("removing the OAuth tokens from the OS keychain: %w", err) + } + + return nil +} + +func (f credentialsFile) find(endpoint string) (Token, bool) { + for _, tok := range f.Tokens { + if tok.Endpoint == endpoint { + return tok, true + } + } + + return Token{}, false +} + +func (f *credentialsFile) upsert(tok Token) { + for i, existing := range f.Tokens { + if existing.Endpoint == tok.Endpoint { + f.Tokens[i] = tok + return + } + } + + f.Tokens = append(f.Tokens, tok) } func readCredentials(path string) (credentialsFile, error) { diff --git a/internal/oauth/store_test.go b/internal/oauth/store_test.go index 12c3c1e..6fd5169 100644 --- a/internal/oauth/store_test.go +++ b/internal/oauth/store_test.go @@ -1,6 +1,8 @@ package oauth import ( + "errors" + "os" "path/filepath" "testing" "time" @@ -9,30 +11,204 @@ import ( "github.com/stretchr/testify/require" ) -func TestTokenStore_RoundTrip(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "credentials.yaml") - tok := Token{ +func testToken() Token { + return Token{ Endpoint: "grpc.development-cloud.qdrant.io:443", AccessToken: "access", RefreshToken: "refresh", Scope: "manage", Expiry: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC), } +} + +// failingSecrets is a machine without a keychain. +type failingSecrets struct{} + +var errNoKeychain = errors.New("no keychain") + +func (failingSecrets) Set(string, string, string) error { return errNoKeychain } +func (failingSecrets) Get(string, string) (string, error) { return "", errNoKeychain } +func (failingSecrets) Delete(string, string) error { return errNoKeychain } + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + + return string(data) +} + +func TestTokenStore_KeepsTheTokensInTheKeychain(t *testing.T) { + t.Parallel() + + secrets := newMemorySecrets() + path := filepath.Join(t.TempDir(), "credentials.yaml") + tok := testToken() - require.NoError(t, SaveToken(path, tok)) + saved, err := saveToken(secrets, StoreAuto, path, tok) + require.NoError(t, err) + assert.Equal(t, StoreKeychain, saved.Store) + require.NoError(t, saved.KeychainErr) + + file := readFile(t, path) + assert.NotContains(t, file, "access_token") + assert.NotContains(t, file, "refresh_token") + assert.Contains(t, file, "store: keychain") - got, ok, err := LoadToken(path, tok.Endpoint) + got, ok, err := loadToken(secrets, path, tok.Endpoint) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "access", got.AccessToken) assert.Equal(t, "refresh", got.RefreshToken) + assert.Equal(t, "manage", got.Scope) + + info, ok, err := LoadTokenInfo(path, tok.Endpoint) + require.NoError(t, err) + require.True(t, ok) + assert.Empty(t, info.AccessToken, "LoadTokenInfo does not read the keychain") + assert.Equal(t, tok.Expiry, info.Expiry) - require.NoError(t, DeleteToken(path, tok.Endpoint)) - _, ok, err = LoadToken(path, tok.Endpoint) + require.NoError(t, deleteToken(secrets, path, tok.Endpoint)) + _, ok, err = loadToken(secrets, path, tok.Endpoint) require.NoError(t, err) assert.False(t, ok) + assert.Empty(t, secrets.secrets, "logout removes the keychain entries") +} + +func TestTokenStore_FallsBackToTheFileWithoutAKeychain(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "credentials.yaml") + tok := testToken() + + saved, err := saveToken(failingSecrets{}, StoreAuto, path, tok) + require.NoError(t, err) + assert.Equal(t, StoreFile, saved.Store) + require.ErrorIs(t, saved.KeychainErr, errNoKeychain) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + got, ok, err := loadToken(failingSecrets{}, path, tok.Endpoint) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "access", got.AccessToken) + assert.Equal(t, "refresh", got.RefreshToken) + + require.NoError(t, deleteToken(failingSecrets{}, path, tok.Endpoint), "no keychain is not an error on logout") +} + +func TestTokenStore_KeychainModeFailsWithoutAKeychain(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "credentials.yaml") + + _, err := saveToken(failingSecrets{}, StoreKeychain, path, testToken()) + require.ErrorIs(t, err, errNoKeychain) + assert.NoFileExists(t, path) +} + +func TestTokenStore_FileModeSkipsTheKeychain(t *testing.T) { + t.Parallel() + + secrets := newMemorySecrets() + path := filepath.Join(t.TempDir(), "credentials.yaml") + + saved, err := saveToken(secrets, StoreFile, path, testToken()) + require.NoError(t, err) + assert.Equal(t, StoreFile, saved.Store) + require.NoError(t, saved.KeychainErr) + assert.Empty(t, secrets.secrets) + assert.Contains(t, readFile(t, path), "refresh_token: refresh") +} + +func TestTokenStore_MovesAFileTokenToTheKeychainOnTheNextSave(t *testing.T) { + t.Parallel() + + secrets := newMemorySecrets() + path := filepath.Join(t.TempDir(), "credentials.yaml") + tok := testToken() + + // A session saved before qcloud used the keychain. + _, err := saveToken(secrets, StoreFile, path, tok) + require.NoError(t, err) + + got, ok, err := loadToken(secrets, path, tok.Endpoint) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "access", got.AccessToken) + + // The next refresh saves it again. + got.AccessToken = "refreshed" + _, err = saveToken(secrets, StoreAuto, path, got) + require.NoError(t, err) + assert.NotContains(t, readFile(t, path), "refresh_token") + + got, _, err = loadToken(secrets, path, tok.Endpoint) + require.NoError(t, err) + assert.Equal(t, "refreshed", got.AccessToken) + assert.Equal(t, "refresh", got.RefreshToken) +} + +func TestTokenStore_MovingBackToTheFileClearsTheKeychain(t *testing.T) { + t.Parallel() + + secrets := newMemorySecrets() + path := filepath.Join(t.TempDir(), "credentials.yaml") + + _, err := saveToken(secrets, StoreAuto, path, testToken()) + require.NoError(t, err) + _, err = saveToken(secrets, StoreFile, path, testToken()) + require.NoError(t, err) + + assert.Empty(t, secrets.secrets) +} + +func TestTokenStore_MissingKeychainEntryAsksToLogIn(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "credentials.yaml") + tok := testToken() + _, err := saveToken(newMemorySecrets(), StoreAuto, path, tok) + require.NoError(t, err) + + // Another machine's keychain, or an entry deleted by hand. + _, _, err = loadToken(newMemorySecrets(), path, tok.Endpoint) + require.ErrorContains(t, err, "qcloud auth login") +} + +func TestTokenStore_ConfigDirectoriesHaveSeparateKeychainEntries(t *testing.T) { + t.Parallel() + + secrets := newMemorySecrets() + a := filepath.Join(t.TempDir(), "credentials.yaml") + b := filepath.Join(t.TempDir(), "credentials.yaml") + tok := testToken() + + _, err := saveToken(secrets, StoreAuto, a, tok) + require.NoError(t, err) + + tok.AccessToken = "other" + _, err = saveToken(secrets, StoreAuto, b, tok) + require.NoError(t, err) + + got, _, err := loadToken(secrets, a, tok.Endpoint) + require.NoError(t, err) + assert.Equal(t, "access", got.AccessToken) +} + +func TestStoreMode(t *testing.T) { + for value, want := range map[string]string{"": StoreAuto, "auto": StoreAuto, "keychain": StoreKeychain, "file": StoreFile} { + t.Setenv(TokenStoreEnv, value) + got, err := storeMode() + require.NoError(t, err) + assert.Equal(t, want, got) + } + + t.Setenv(TokenStoreEnv, "vault") + _, err := storeMode() + require.Error(t, err) } func TestToken_Expired(t *testing.T) { diff --git a/internal/state/state.go b/internal/state/state.go index d3a8089..61715e6 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -175,7 +175,7 @@ func (s *State) oauthAccessToken(ctx context.Context) (string, error) { return "", fmt.Errorf("refresh OAuth token: %w (run \"qcloud auth login\")", err) } - if err := oauth.SaveToken(path, refreshed); err != nil { + if _, err := oauth.SaveToken(path, refreshed); err != nil { return "", err } From 2981ba3203d53e2436ee8621be827dbbe467d0d4 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Fri, 23 Oct 2026 16:16:00 +0200 Subject: [PATCH 23/25] [CP-768] chore(auth): review fixes: neutral test names, no backend names in comments Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cmd/auth/auth_test.go | 4 ++-- internal/cmd/auth/contexts_internal_test.go | 4 ++-- internal/oauth/login.go | 9 +++++---- internal/oauth/store.go | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index 0c6f332..db49422 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -216,7 +216,7 @@ func TestAuthLogin_SeveralAccountsGetAContextEachAndTheFirstIsSelected(t *testin env := testutil.NewBareTestEnv(t) prepareLogin(t, env) grantAccounts(env, - &accountv1.Account{Id: "a1", Name: "Robert Stam - Base Account"}, + &accountv1.Account{Id: "a1", Name: "Jane Doe - Base Account"}, &accountv1.Account{Id: "a2", Name: "Andrey Vasnetsov - Base Account"}, ) cfg := tempConfig(t, "") @@ -224,7 +224,7 @@ func TestAuthLogin_SeveralAccountsGetAContextEachAndTheFirstIsSelected(t *testin stdout, _, err := testutil.Exec(t, env, "auth", "login", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") require.NoError(t, err) assert.Contains(t, stdout, "Contexts for your 2 accounts:") - assert.Contains(t, stdout, " * dev-robert-stam-base-account (Robert Stam - Base Account)") + assert.Contains(t, stdout, " * dev-jane-doe-base-account (Jane Doe - Base Account)") assert.Contains(t, stdout, " dev-andrey-vasnetsov-base-account (Andrey Vasnetsov - Base Account)") assert.Contains(t, stdout, "Switch with: qcloud context use ") diff --git a/internal/cmd/auth/contexts_internal_test.go b/internal/cmd/auth/contexts_internal_test.go index eda02fa..c7c7499 100644 --- a/internal/cmd/auth/contexts_internal_test.go +++ b/internal/cmd/auth/contexts_internal_test.go @@ -22,7 +22,7 @@ func TestContextPrefix(t *testing.T) { func TestSlug(t *testing.T) { t.Parallel() - assert.Equal(t, "robert-stam-base-account", slug("Robert Stam - Base Account")) - assert.Equal(t, "cloud-test-functional-qdrant-com-base-account", slug("cloud-test+functional@qdrant.com - Base Account")) + assert.Equal(t, "jane-doe-base-account", slug("Jane Doe - Base Account")) + assert.Equal(t, "dummy-test-example-com-base-account", slug("dummy+test@example.com - Base Account")) assert.Empty(t, slug(" -- ")) } diff --git a/internal/oauth/login.go b/internal/oauth/login.go index bc29c36..8a1764b 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -21,7 +21,7 @@ const ( // requiredScopes are requested on every login next to read-only or manage. offline_access gives a // refresh token. openid and email make Auth0 put email_verified in the access token, which -// iam-service requires: Auth0 drops email_verified when an Action sets it, as it is a reserved +// Qdrant Cloud requires: Auth0 drops email_verified when an Action sets it, as it is a reserved // OpenID Connect claim name, so only the email scope brings it in. var requiredScopes = []string{"openid", "email", offlineScope} @@ -249,9 +249,10 @@ func loopbackWaiter(ln net.Listener, issuer string) 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. +// CSRF value from /authorize, not the authentication server's consent-page +// redirect state. iss is checked when the authentication server sends it +// (RFC 9207). A missing iss is allowed so login still works before the server +// sends it. func authorizationCallback(q url.Values, wantState, wantIssuer string) (string, error) { if errParam := q.Get("error"); errParam != "" { msg := q.Get("error_description") diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 2cf3d59..d39d649 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -24,7 +24,7 @@ type Token struct { // Scope is the requested scope: what qcloud asked Auth0 for, and what the token's scope claim says. Scope string `yaml:"requested_scope,omitempty"` // GrantedScope is what the user granted on the consent page (read-only or manage), which can be - // narrower than the requested scope. iam-service enforces it on every call. + // narrower than the requested scope. Qdrant Cloud enforces it on every call. GrantedScope string `yaml:"granted_scope,omitempty"` Issuer string `yaml:"issuer,omitempty"` ClientID string `yaml:"client_id,omitempty"` From 511416f90fc870a4cd16126563c7c2c4b7b7c2ba Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Fri, 23 Oct 2026 17:42:40 +0200 Subject: [PATCH 24/25] [CP-768] fix(auth): keep polling the device login on Auth0's authorization_pending The token error kept only the description, and the poll looked for the code in it. Auth0's description ("User has yet to authorize device code.") does not contain the code, so the first poll ended the login. TokenError keeps both. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/oauth/device.go | 21 ++++++++++++++------- internal/oauth/login_test.go | 15 +++++++++------ internal/oauth/token.go | 24 ++++++++++++++++++------ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/internal/oauth/device.go b/internal/oauth/device.go index 7889348..4c5d0b8 100644 --- a/internal/oauth/device.go +++ b/internal/oauth/device.go @@ -3,6 +3,7 @@ package oauth import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -140,15 +141,21 @@ func (d Discoverer) deviceLogin(ctx context.Context, opts LoginOptions, disc Dis } } -// 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. +// nextDevicePollInterval applies RFC 8628 polling, by the token error's code (its +// description is free text: Auth0 says "User has yet to authorize device code."). +// 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"): + var tokErr *TokenError + if !errors.As(err, &tokErr) { + return interval, false + } + + switch tokErr.Code { + case "slow_down": return interval + 5*time.Second, true - case strings.Contains(msg, "authorization_pending"): + case "authorization_pending": return interval, true default: return interval, false diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index 241713f..61cdbc0 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -119,18 +119,21 @@ func TestNextDevicePollInterval(t *testing.T) { assert.False(t, again) assert.Equal(t, 5*time.Second, next) - next, again = nextDevicePollInterval(5*time.Second, errString("token endpoint: authorization_pending")) + // Auth0's description does not contain the code; the poll goes by the code. + next, again = nextDevicePollInterval(5*time.Second, &TokenError{Code: "authorization_pending", Description: "User has yet to authorize device code."}) assert.True(t, again) assert.Equal(t, 5*time.Second, next) - next, again = nextDevicePollInterval(5*time.Second, errString("token endpoint: slow_down")) + next, again = nextDevicePollInterval(5*time.Second, &TokenError{Code: "slow_down"}) assert.True(t, again) assert.Equal(t, 10*time.Second, next) -} -type errString string + _, again = nextDevicePollInterval(5*time.Second, &TokenError{Code: "access_denied", Description: "authorization_pending"}) + assert.False(t, again, "a description that looks like a code does not keep polling") -func (e errString) Error() string { return string(e) } + _, again = nextDevicePollInterval(5*time.Second, &TokenError{Code: "expired_token"}) + assert.False(t, again) +} func TestLogin_DeviceUsesAudience(t *testing.T) { t.Parallel() @@ -180,7 +183,7 @@ func TestLogin_DeviceUsesAudience(t *testing.T) { polls++ if polls == 1 { w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending", "error_description": "authorization_pending"}) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending", "error_description": "User has yet to authorize device code."}) return } diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 6e76535..e0988e0 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -47,12 +47,7 @@ func (d Discoverer) exchange(ctx context.Context, tokenURL string, form url.Valu } if tr.Error != "" { - desc := tr.ErrorDesc - if desc == "" { - desc = tr.Error - } - - return Token{}, fmt.Errorf("token endpoint: %s", desc) + return Token{}, &TokenError{Code: tr.Error, Description: tr.ErrorDesc} } if resp.StatusCode != http.StatusOK { @@ -107,3 +102,20 @@ func (d Discoverer) Refresh(ctx context.Context, tok Token, now time.Time) (Toke return d.exchange(ctx, meta.TokenEndpoint, form, now, tok) } + +// TokenError is an OAuth error response from the token endpoint (RFC 6749 §5.2). +type TokenError struct { + // Code is the error code, such as invalid_grant or, while polling a device login, + // authorization_pending. + Code string + // Description is the human-readable error_description, if the server sent one. + Description string +} + +func (e *TokenError) Error() string { + if e.Description == "" { + return "token endpoint: " + e.Code + } + + return "token endpoint: " + e.Description +} From b1f5b64b41f9d308a87dafe4837df95c66023c99 Mon Sep 17 00:00:00 2001 From: Robert-Stam Date: Fri, 23 Oct 2026 20:53:21 +0200 Subject: [PATCH 25/25] [CP-768] feat(auth): print the login URL only when the browser does not open When the browser opens, login prints one line. When it cannot be opened, it prints the URL and a --device hint and keeps waiting, instead of failing. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/oauth/login.go | 13 ++++++--- internal/oauth/login_test.go | 55 +++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/internal/oauth/login.go b/internal/oauth/login.go index 8a1764b..4a8991e 100644 --- a/internal/oauth/login.go +++ b/internal/oauth/login.go @@ -148,14 +148,19 @@ func (d Discoverer) codeLogin(ctx context.Context, opts LoginOptions, disc Disco } authorizeURL := as.AuthorizationEndpoint + "?" + q.Encode() + openErr := errors.New("no browser opener") if opts.OpenURL != nil { - if err := opts.OpenURL(authorizeURL); err != nil { - return Token{}, fmt.Errorf("open browser: %w", err) - } + openErr = opts.OpenURL(authorizeURL) } + // The URL is only worth printing when the browser did not open: it is long, and the login + // waits on the loopback callback either way. if opts.Output != nil { - fmt.Fprintf(opts.Output, "Opening the browser to log in. If it does not open, visit:\n%s\n", authorizeURL) + if openErr == nil { + fmt.Fprintln(opts.Output, "Opening the browser to log in.") + } else { + fmt.Fprintf(opts.Output, "Could not open a browser (%v). Open this URL to log in, or use \"qcloud auth login --device\":\n%s\n", openErr, authorizeURL) + } } code, err := wait(ctx, redirectURL, state) diff --git a/internal/oauth/login_test.go b/internal/oauth/login_test.go index 61cdbc0..4c760c8 100644 --- a/internal/oauth/login_test.go +++ b/internal/oauth/login_test.go @@ -3,11 +3,13 @@ package oauth import ( "context" "encoding/json" + "errors" "io" "net" "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" @@ -53,22 +55,43 @@ func TestLogin_AuthorizationCode(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - d := Discoverer{HTTPClient: rewriteClient(t, srv.URL)} - tok, disc, err := d.Login(context.Background(), LoginOptions{ - Endpoint: "grpc.development-cloud.qdrant.io:443", - ClientID: "cli-client", - Scope: "manage", - RedirectURL: "http://127.0.0.1:1/callback", - WaitCode: func(context.Context, string, string) (string, error) { - return "test-code", nil - }, - OpenURL: func(string) error { return nil }, - Output: io.Discard, - }) - require.NoError(t, err) - assert.Equal(t, "at-1", tok.AccessToken) - assert.Equal(t, "rt-1", tok.RefreshToken) - assert.Equal(t, sourceProtectedResource, disc.Source) + for _, tc := range []struct { + name string + openErr error + wantURL bool + }{ + {name: "browser opens: no URL", openErr: nil, wantURL: false}, + {name: "no browser: the URL, and the login still waits", openErr: errors.New("no display"), wantURL: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var out strings.Builder + d := Discoverer{HTTPClient: rewriteClient(t, srv.URL)} + tok, disc, err := d.Login(context.Background(), LoginOptions{ + Endpoint: "grpc.development-cloud.qdrant.io:443", + ClientID: "cli-client", + Scope: "manage", + RedirectURL: "http://127.0.0.1:1/callback", + WaitCode: func(context.Context, string, string) (string, error) { + return "test-code", nil + }, + OpenURL: func(string) error { return tc.openErr }, + Output: &out, + }) + require.NoError(t, err) + assert.Equal(t, "at-1", tok.AccessToken) + assert.Equal(t, "rt-1", tok.RefreshToken) + assert.Equal(t, sourceProtectedResource, disc.Source) + + if tc.wantURL { + assert.Contains(t, out.String(), "https://login.development-cloud.qdrant.io/authorize?") + assert.Contains(t, out.String(), "--device") + } else { + assert.Equal(t, "Opening the browser to log in.\n", out.String()) + } + }) + } } func TestLogin_RequiresClientID(t *testing.T) {