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/docs/reference/qcloud.md b/docs/reference/qcloud.md index cf48d40..4fbfcec 100644 --- a/docs/reference/qcloud.md +++ b/docs/reference/qcloud.md @@ -6,8 +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 @@ -29,6 +33,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..73d0893 --- /dev/null +++ b/docs/reference/qcloud_auth.md @@ -0,0 +1,61 @@ +## 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 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 + +When OAuth is enabled, the gateway also advertises the issuer at the +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. + +### Examples + +``` +# Discover the login URL for the current endpoint +qcloud auth discover + +# Browser login (authorization code + PKCE on 127.0.0.1) +qcloud auth login + +# 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..f40994c --- /dev/null +++ b/docs/reference/qcloud_auth_discover.md @@ -0,0 +1,47 @@ +## 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 (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] +``` + +### Examples + +``` +qcloud auth discover +``` + +### 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..a568812 --- /dev/null +++ b/docs/reference/qcloud_auth_login.md @@ -0,0 +1,80 @@ +## qcloud auth login + +Log in with a browser or device code + +### Synopsis + +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), 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 +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 +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. +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. +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. + +``` +qcloud auth login [flags] +``` + +### Examples + +``` +# Browser login (default production endpoint) +qcloud auth login + +# Read-only session +qcloud auth login --scope read-only + +# Device code +qcloud auth login --device +``` + +### Options + +``` + --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) + --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..1730b7a --- /dev/null +++ b/docs/reference/qcloud_auth_status.md @@ -0,0 +1,44 @@ +## 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 +``` + +### 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/docs/reference/qcloud_context_set.md b/docs/reference/qcloud_context_set.md index ee6a24f..3be0db6 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 an API key +under a name, so you can switch between environments with +"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: diff --git a/go.mod b/go.mod index 0b30bf7..29d7a0c 100644 --- a/go.mod +++ b/go.mod @@ -15,21 +15,26 @@ 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 gopkg.in/yaml.v3 v3.0.1 ) 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 @@ -55,5 +60,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/go.sum b/go.sum index 8b61195..356815d 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/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"))) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 9268521..3474194 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" @@ -32,8 +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`, @@ -73,6 +78,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..ac5cdd8 --- /dev/null +++ b/internal/cmd/auth/auth.go @@ -0,0 +1,45 @@ +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 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 + +When OAuth is enabled, the gateway also advertises the issuer at the +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 +qcloud auth discover + +# Browser login (authorization code + PKCE on 127.0.0.1) +qcloud auth login + +# 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..db49422 --- /dev/null +++ b/internal/cmd/auth/auth_test.go @@ -0,0 +1,360 @@ +package auth_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "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" +) + +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") +} + +// 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()) + + 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": accessToken, + "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") +} + +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) + grantAccounts(env) + 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, "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) + + 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", "--config", cfg, "--endpoint", devEndpoint) + require.NoError(t, err) + + _, _, err = testutil.Exec(t, env, "auth", "token", "--config", cfg, "--endpoint", devEndpoint) + require.Error(t, err) +} + +func TestAuthLogin_CreatesAndSelectsTheContextOfTheOnlyAccount(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + 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", "--config", cfg, "--endpoint", devEndpoint, "--client-id", "cli-app") + require.NoError(t, err) + assert.Contains(t, stdout, "Logged in, account Only Account (context dev-only-account).") + assert.NotContains(t, stdout, "Switch to it") + + // 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) { + 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") + grantAccounts(env, &accountv1.Account{Id: "acc-only", Name: "Only 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, "Logged in as ada@example.com, account Only Account, read only (context dev-only-account).") + + 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 TestAuthLogin_SeveralAccountsGetAContextEachAndTheFirstIsSelected(t *testing.T) { + env := testutil.NewBareTestEnv(t) + prepareLogin(t, env) + grantAccounts(env, + &accountv1.Account{Id: "a1", Name: "Jane Doe - 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-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 ") + + // 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) { + 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-only-account + endpoint: `+devEndpoint+` + api_key: secret + account_id: key-acc + - name: dev-old-account + endpoint: `+devEndpoint+` + 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") + + 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") +} + +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) + + stdout, _, err := testutil.Exec(t, env, "auth", "logout", "--config", cfg, "--endpoint", devEndpoint) + require.NoError(t, err) + assert.Contains(t, stdout, "Logged out; removed contexts dev-only-account.") + + listOut, _, err := testutil.Exec(t, env, "context", "list", "--config", cfg) + require.NoError(t, err) + assert.NotContains(t, listOut, "dev-only-account") +} + +func TestAuthStatus_ShowsWhereTheAccountComesFrom(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) + + 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", "--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") +} + +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/contexts.go b/internal/cmd/auth/contexts.go new file mode 100644 index 0000000..49f0725 --- /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. +// 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{} + 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) > 0 && 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..c7c7499 --- /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, "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/cmd/auth/discover.go b/internal/cmd/auth/discover.go new file mode 100644 index 0000000..8b95374 --- /dev/null +++ b/internal/cmd/auth/discover.go @@ -0,0 +1,52 @@ +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 (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", + 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 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, ", ")) + } + + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go new file mode 100644 index 0000000..7abaa71 --- /dev/null +++ b/internal/cmd/auth/login.go @@ -0,0 +1,186 @@ +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" +) + +func newLoginCommand(s *state.State) *cobra.Command { + return base.Cmd{ + 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), 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 +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 +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. +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. +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) +qcloud auth login + +# Read-only session +qcloud auth login --scope read-only + +# Device code +qcloud auth login --device`, + 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"), "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 + }, + 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, _, err := s.OAuthDiscoverer().Login(cmd.Context(), opts) + if err != nil { + return err + } + + claims := oauth.ReadTokenClaims(tok.AccessToken) + tok.GrantedScope = claims.GrantedScope + + path := oauth.CredentialsPath(s.Config.ConfigFilePath()) + 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 + } + + access := "" + if 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) + } + + printLoginResult(cmd, who, access, accounts, names, selected, s.Config.CurrentContext()) + + return nil + }, + }.CobraCommand(s) +} + +// accountLookupTimeout bounds the account lookup after login; the login itself has already succeeded. +const accountLookupTimeout = 15 * time.Second + +// 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() != "" { + fmt.Fprintln(cmd.ErrOrStderr(), "An API key is set, so commands use it instead of this login; no contexts were created.") + return nil, false + } + + ctx, cancel := context.WithTimeout(cmd.Context(), accountLookupTimeout) + defer cancel() + + client, err := s.Client(ctx) + if err == nil { + var resp *accountv1.ListAccountsResponse + if resp, err = client.Account().ListAccounts(ctx, &accountv1.ListAccountsRequest{}); err == nil { + return resp.GetItems(), true + } + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Could not list your accounts (%v); no contexts were created, pass --account-id to commands.\n", err) + + return nil, false +} + +// 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, current 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 { + marker := " " + if name == current { + marker = "*" + } + + fmt.Fprintf(out, " %s %s (%s)\n", marker, name, accounts[i].GetName()) + } + + fmt.Fprintln(out, "Switch with: qcloud context use ") + } +} diff --git a/internal/cmd/auth/logout.go b/internal/cmd/auth/logout.go new file mode 100644 index 0000000..29b8628 --- /dev/null +++ b/internal/cmd/auth/logout.go @@ -0,0 +1,62 @@ +package auth + +import ( + "fmt" + "strings" + + "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 + } + + // 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 + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/auth/status.go b/internal/cmd/auth/status.go new file mode 100644 index 0000000..4d535c6 --- /dev/null +++ b/internal/cmd/auth/status.go @@ -0,0 +1,103 @@ +package auth + +import ( + "fmt" + "os" + "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`, + 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) + 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.LoadTokenInfo(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) + // The access the user granted on the consent page. The requested scope (what qcloud asked for) + // is stored too; showing it here next to this only confuses. + if tok.GrantedScope != "" { + fmt.Fprintf(out, "Access: %s\n", oauth.ScopeLabel(tok.GrantedScope)) + } + + if id, source := accountSource(cmd, s); id != "" { + fmt.Fprintf(out, "Account: %s (from %s)\n", id, source) + } else { + fmt.Fprintln(out, "Account: none; select a context with \"qcloud context use \" or pass --account-id") + } + + if !tok.Expiry.IsZero() { + fmt.Fprintf(out, "Expiry: %s\n", tok.Expiry.UTC().Format(time.RFC3339)) + } + + return nil + }, + }.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, 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" + } + + 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 + } + } + + return "", "" +} diff --git a/internal/cmd/auth/token.go b/internal/cmd/auth/token.go new file mode 100644 index 0000000..eeab90f --- /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,"granted_scope":%q}`+"\n", tok.AccessToken, tok.TokenType, tok.Scope, tok.GrantedScope) + return nil + } + + fmt.Fprintln(cmd.OutOrStdout(), tok.AccessToken) + return nil + }, + }.CobraCommand(s) +} 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/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..aea50c1 100644 --- a/internal/cmd/context/set.go +++ b/internal/cmd/context/set.go @@ -1,7 +1,6 @@ package context import ( - "errors" "fmt" "regexp" "sort" @@ -42,9 +41,11 @@ qcloud context set staging --api-key-helper 1password --api-key-ref op://vault/q 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 an API key +under a name, so you can switch between environments with +"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: @@ -94,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 log out to remove it", name) + } if ep, ok := flagChangedWithValue(cmd, "endpoint"); ok { ctx.Endpoint = ep @@ -122,16 +126,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..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" @@ -309,3 +310,24 @@ func TestContextSet_APIKeyCommandClearsPlainKey(t *testing.T) { assert.Equal(t, "echo new-command-key", ctxE.APIKeyCommand) assert.Empty(t, ctxE.APIKey) } + +func TestContextSet_RefusesAContextManagedByLogin(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", "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-my-account") + require.NotNil(t, ctx) + assert.Equal(t, "acc-1", ctx.AccountID, "unchanged") +} 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/callback.html b/internal/oauth/callback.html new file mode 100644 index 0000000..6e7bcbc --- /dev/null +++ b/internal/oauth/callback.html @@ -0,0 +1,112 @@ + + + + + +{{if .OK}}Logged in{{else}}Login failed{{end}} · Qdrant Cloud CLI + + + +
+ +
+{{if .OK}} +

Login Complete

+{{else}} +

Login Failed

+{{end}} + +{{if .OK}} +

The Qdrant Cloud CLI is now logged in to your Qdrant Cloud account.

+

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

+{{else}} +

The Qdrant Cloud CLI could not log in.

+

+ + 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/claims.go b/internal/oauth/claims.go new file mode 100644 index 0000000..70d4829 --- /dev/null +++ b/internal/oauth/claims.go @@ -0,0 +1,49 @@ +package oauth + +import ( + "encoding/base64" + "encoding/json" + "strings" +) + +// 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 TokenClaims{} + } + + payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) + if err != nil { + return TokenClaims{} + } + + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + return TokenClaims{} + } + + 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 scope +} diff --git a/internal/oauth/claims_test.go b/internal/oauth/claims_test.go new file mode 100644 index 0000000..2245aa4 --- /dev/null +++ b/internal/oauth/claims_test.go @@ -0,0 +1,25 @@ +package oauth + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadTokenClaims(t *testing.T) { + t.Parallel() + + 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/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/device.go b/internal/oauth/device.go new file mode 100644 index 0000000..4c5d0b8 --- /dev/null +++ b/internal/oauth/device.go @@ -0,0 +1,205 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "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") + } + + // 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}, + "audience": {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 { + if dc.VerificationURIComplete != "" { + fmt.Fprintf(opts.Output, "Open %s and confirm the code %s.\n", verify, dc.UserCode) + } else { + fmt.Fprintf(opts.Output, "Open %s and enter the 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}, + } + + tok, err := d.exchange(ctx, as.TokenEndpoint, tokForm, opts.Now(), base) + if err == nil { + return tok, nil + } + + next, again := nextDevicePollInterval(interval, err) + if !again { + return Token{}, err + } + + if next != interval { + interval = next + ticker.Reset(interval) + } + } +} + +// 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) { + var tokErr *TokenError + if !errors.As(err, &tokErr) { + return interval, false + } + + switch tokErr.Code { + case "slow_down": + return interval + 5*time.Second, true + case "authorization_pending": + return interval, true + default: + return interval, false + } +} + +// 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..9fe6d62 --- /dev/null +++ b/internal/oauth/discover.go @@ -0,0 +1,127 @@ +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 + // 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. +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, err := d.fetchPRM(ctx, hosts.ProtectedResourceMetadataURL()) + if err != nil { + // OAuth may not be enabled yet; hostname inference still works. + 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] != "" { + 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) 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..b5c207f --- /dev/null +++ b/internal/oauth/discover_test.go @@ -0,0 +1,54 @@ +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) + assert.Contains(t, disc.MetadataError, assert.AnError.Error()) +} + +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..4a8991e --- /dev/null +++ b/internal/oauth/login.go @@ -0,0 +1,285 @@ +package oauth + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" +) + +const ( + defaultScope = "manage" + 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 +// 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} + +// 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) + +// 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 == "" { + 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) + 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) + for _, required := range requiredScopes { + if !slices.Contains(parts, required) { + parts = append(parts, required) + } + } + + 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, disc.Issuer) + } 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() + + openErr := errors.New("no browser opener") + if opts.OpenURL != nil { + 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 { + 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) + 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() + } + + 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 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, 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) { + code, err := authorizationCallback(r.URL.Query(), wantState, issuer) + if err != nil { + writeCallbackPage(w, http.StatusBadRequest, err.Error()) + errCh <- err + + return + } + + writeCallbackPage(w, http.StatusOK, "") + 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) + }() + + select { + case <-ctx.Done(): + return "", ctx.Err() + case err := <-errCh: + return "", err + case code := <-codeCh: + return code, nil + } + } +} + +// authorizationCallback checks the loopback redirect. state is the client's +// 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") + 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 new file mode 100644 index 0000000..4c760c8 --- /dev/null +++ b/internal/oauth/login_test.go @@ -0,0 +1,315 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "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) + + 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) { + 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") +} + +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) + + // 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, &TokenError{Code: "slow_down"}) + assert.True(t, again) + assert.Equal(t, 10*time.Second, next) + + _, 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") + + _, again = nextDevicePollInterval(5*time.Second, &TokenError{Code: "expired_token"}) + assert.False(t, again) +} + +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": "User has yet to authorize device code."}) + 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")) +} + +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") +} + +func TestLoopbackWaiter_Pages(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, query, want string + status int + }{ + {"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) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + wait := loopbackWaiter(ln, "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") + }) + } +} + +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")) +} 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/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 new file mode 100644 index 0000000..d39d649 --- /dev/null +++ b/internal/oauth/store.go @@ -0,0 +1,234 @@ +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"` + // 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"` + // 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. Qdrant Cloud 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"` +} + +// 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, 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 + } + + 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 + } + + 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) +} + +func saveToken(secrets secretStore, mode, path string, tok Token) (Saved, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return Saved{}, fmt.Errorf("creating credentials dir: %w", err) + } + + file, err := readCredentials(path) + if err != nil { + return Saved{}, err + } + + 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 + } + } + + // 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) + } + + file.upsert(tok) + + return saved, writeCredentials(path, file) +} + +// 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 { + filtered = append(filtered, tok) + } + } + + file.Tokens = filtered + 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) { + 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..6fd5169 --- /dev/null +++ b/internal/oauth/store_test.go @@ -0,0 +1,221 @@ +package oauth + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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() + + 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(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(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) { + 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..e0988e0 --- /dev/null +++ b/internal/oauth/token.go @@ -0,0 +1,121 @@ +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 != "" { + return Token{}, &TokenError{Code: tr.Error, Description: tr.ErrorDesc} + } + + 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) +} + +// 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 +} 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/config/config.go b/internal/state/config/config.go index e748274..a2e575d 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 with an empty API key (use --api-key, --api-key-command, or --api-key-helper; for OAuth, run \"qcloud auth login\")") + } + + 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 9f213bc..61715e6 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 {