Skip to content

[CP-768] feat(auth): add login with endpoint-inferred Auth0 issuer - #219

Open
qdrant-cloud-bot wants to merge 28 commits into
mainfrom
feat/oauth-auth-login
Open

qdrant-cloud-bot wants to merge 28 commits into
mainfrom
feat/oauth-auth-login

Conversation

@qdrant-cloud-bot

@qdrant-cloud-bot qdrant-cloud-bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Add qcloud auth discover|login|logout|status|token.
  • Login URL is not a new config field: it comes from QDRANT_CLOUD_ENDPOINT / --endpoint (grpc.<cluster>.qdrant.io → login.<cluster>.qdrant.io).
  • Prefer the gateway’s unauthenticated RFC 9728 document at https://api.<cluster>.qdrant.io/.well-known/oauth-protected-resource when OAuth is enabled; fall back to hostname inference when it is not.
  • Browser PKCE loopback (default) and --device. The loopback listener uses the first free port of 48271, 48272 and 48273: Auth0 matches a loopback callback's port exactly (a random port gets "Callback URL mismatch"), so the three http://127.0.0.1:<port>/callback URLs are registered on the CLI's Auth0 application. tokens in credentials.yaml (0600). API keys still win when set. gRPC sends Authorization: Bearer after login.
  • A context per granted account. After a login, qcloud lists the granted accounts (only the consented ones, qdrant-cloud-cluster-api#5223) and creates an OAuth context for each (auth: oauth, no API key), named <env>-<account name> (no prefix for production). One account: it becomes the current context unless one is already set. Several: the login lists them for qcloud context use. A later login updates them and removes those of accounts no longer granted; auth logout removes them. API key contexts are never touched. A context has exactly one credential: an API key (or command), or OAuth for the contexts the login creates. context set only creates API key contexts and refuses to change OAuth ones (log in again instead); context show / list show the credential. auth status shows the active context and where the account comes from.
  • Granted scope. The token's scope is the requested scope (manage by default); the consent page can narrow it. The post-login Action puts the granted scope in the token as https://cloud.qdrant.io/granted_scope (qdrant-cloud-iam-service#543, qdrant-cloud-ui#3238); qcloud stores requested_scope and granted_scope in credentials.yaml, prints e.g. Logged in as ada@example.com, account Production, read only (context production). and shows both in auth status. Without the claim (before those are deployed) the scope is simply not printed.
  • Minimal login output: the browser line with its fallback URL, then one result line; discovery details are in auth discover / auth status.
Env Endpoint Inferred login
development grpc.development-cloud.qdrant.io:443 https://login.development-cloud.qdrant.io/
staging grpc.staging-cloud.qdrant.io:443 https://login.staging-cloud.qdrant.io/
production grpc.cloud.qdrant.io:443 https://login.cloud.qdrant.io/

The "Qdrant Cloud CLI" client id is built in per login URL (internal/oauth/clientid.go): each Auth0 tenant generates its own, and a native app's id is public. Development is set; staging and production are TBD until their apps exist, and ask for --client-id / QDRANT_CLOUD_OAUTH_CLIENT_ID meanwhile. qcloud auth discover needs no client id.

Test plan

  • qcloud auth discover --endpoint grpc.development-cloud.qdrant.io:443 (inference when PRM is down)
  • Unit tests for PRM override, login token store, Bearer interceptor
  • golangci-lint + go test on affected packages
  • Once OAuth is enabled on development: qcloud auth login --endpoint grpc.development-cloud.qdrant.io:443 (no --client-id)
  • After api-gw OAuth is on: discover should report source protected-resource metadata

Discover the login URL from QDRANT_CLOUD_ENDPOINT (grpc.<cluster> →
login.<cluster>), preferring unauthenticated API gateway protected-resource
metadata when OAuth is enabled.
@qdrant-cloud-bot
qdrant-cloud-bot requested a review from a team October 10, 2026 06:34
@qdrant-cloud-bot
qdrant-cloud-bot requested a review from a team as a code owner October 10, 2026 06:34
Comment thread docs/reference/qcloud.md
Comment thread docs/reference/qcloud_auth.md Outdated
Comment thread docs/reference/qcloud_auth.md Outdated
Comment thread docs/reference/qcloud_auth.md Outdated
Comment thread docs/reference/qcloud_auth.md Outdated
Present browser login and API keys as alternatives, drop Auth0 and non-prod hosts from generated help.
@qdrant-cloud-bot qdrant-cloud-bot changed the title feat(auth): add login with endpoint-inferred Auth0 issuer [CP-768] feat(auth): add login with endpoint-inferred Auth0 issuer Oct 12, 2026
qdrant-cloud-bot and others added 20 commits October 19, 2026 11:04
* [CP-768] fix(auth): send audience on device-code login

Auth0 does not apply the resource parameter on /oauth/device/code, so that request must use audience. The poll stays a plain device-code grant, backs off on slow_down, and the loopback callback checks iss when Auth0 sends it.

* test(auth): keep require out of http handlers in device login test

golangci-lint (testifylint go-require, wsl_v5) flagged the device-login test.
A form parse error now returns 400 from the handler, which still fails the login.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Robert-Stam <robert.stam@qdrant.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Auth0 matches a loopback callback's port exactly, so a random port fails
with Callback URL mismatch. Try 48271, 48272 and 48273 in order; each is
registered on the CLI's Auth0 application.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each Auth0 tenant generates its own client id for the native app, and a
native app's id is public, so ship them keyed by issuer: users no longer
pass --client-id. Development is set; staging and production are TBD
until their apps exist, and ask for --client-id meanwhile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A self-contained page (inline Qdrant mark and terminal icon, no fonts,
scripts or other requests, light and dark) for success and failure,
instead of plain text. The failure detail is HTML-escaped; strict CSP.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ogin

Same grid background, wordmark above a white card, title and tiles as
the Auth0 Universal Login pages. Still self-contained: the grid image
and logos are inline, the system font replaces the web font.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… was not used

On development, discover fell back to endpoint inference while curl got
the metadata; the reason was swallowed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ail_verified

iam-service's ExchangeToken requires email_verified in the Auth0 access
token. The custom-token-claims Action sets it, but Auth0 silently drops
it: email_verified is a reserved OpenID Connect claim name. With the
email scope Auth0 adds it itself, as for the Cloud console. Found on
development: invalid_grant: email not verified in JWT payload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After a login, qcloud lists the accounts once. When the login was granted
exactly one, it is stored with the session and used when no flag,
environment variable or context sets an account; auth status shows it.
With an API key nothing is looked up, and a failed lookup never fails
the login. Needs cluster-api's ListAccounts to return only the grant's
accounts to OAuth callers (qdrant-cloud-cluster-api#5223).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The login printed its discovery details (login URL, source, resource,
scope), which auth discover and auth status already show, and a waiting
line. Now: the browser line with its fallback URL, then one result line,
e.g. "Logged in as ada@example.com, account Production." The email is
read from the token for display only. The device flow prints only the
URL and the code. Warnings stay on stderr.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The token's scope is what qcloud requested (manage by default); the
consent page can narrow it. The post-login Action now puts the granted
scope in the token (https://cloud.qdrant.io/granted_scope). qcloud reads
it at login, stores it as granted_scope next to requested_scope in
credentials.yaml (the stored scope field is renamed accordingly), and
shows it: "Logged in as ..., account ..., read only." and in auth status.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…omes from

auth status now names the active context, says when an API key is set
(commands then use it instead of the login), and shows the account with
its source: --account-id, QDRANT_CLOUD_ACCOUNT_ID, the context, or the
login. OAuth sessions stay keyed by endpoint; a context selects them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The requested scope (what qcloud asked Auth0 for) next to the granted one
only confused; it stays in credentials.yaml and auth token --json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
auth login creates an OAuth context (auth: oauth, no API key) for every
account granted on the consent page, named <env>-<account name>. With one
account it becomes the current context unless one is already set; with
several, the login lists them for qcloud context use. A later login
updates them and removes those of accounts no longer granted; auth logout
removes them. API key contexts and other endpoints' contexts are never
touched; a name clash gets a suffix.

A context now has exactly one credential: an API key (or command) or
--oauth, never both, never none. context show/list show the credential.
The account stored on the OAuth session is gone: contexts replace it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e from login

OAuth contexts are created and updated by qcloud auth login only; a wrong
one is fixed by logging in again. context set now refuses to change a
context the login manages, and only creates API key contexts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
They follow the login: auth login creates and updates them, auth logout
removes them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… current

As with one account, unless a context is already current. The login
marks the current context with * in its list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ope error

When the gateway refuses a call only because the OAuth access is read
only (ErrorInfo INSUFFICIENT_SCOPE, qdrant-cloud-api-gateway#694), qcloud
adds: Run "qcloud auth login --scope manage" and allow manage on the
consent page. Role denials get no hint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The access and refresh tokens go to the OS keychain (macOS Keychain, Windows
Credential Manager, Secret Service on Linux) via go-keyring, as two entries per
endpoint. credentials.yaml keeps only what is not secret, with store: keychain.

- Without a keychain (headless Linux, containers, CI) the tokens fall back to
  credentials.yaml (0600) and login warns.
- QDRANT_CLOUD_TOKEN_STORE=keychain requires the keychain; =file skips it.
- A session stored in the file moves to the keychain on its next refresh.
- Logout removes the keychain entries; auth status does not read them.
- Test binaries use an in-memory store, never the machine's keychain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread internal/cmd/auth/contexts_internal_test.go Outdated
Comment thread internal/cmd/auth/contexts_internal_test.go Outdated
Comment on lines +10 to +11
"https://login.cloud.qdrant.io/": clientIDTBD,
"https://login.staging-cloud.qdrant.io/": clientIDTBD,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

Comment on lines +17 to +18
"https://login.cloud.qdrant.io/", // TBD
"https://login.staging-cloud.qdrant.io/", // TBD

@Robert-Stam Robert-Stam Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

Comment thread internal/oauth/login.go Outdated
Comment thread internal/oauth/login.go Outdated
Robert-Stam and others added 4 commits October 23, 2026 16:16
…mes in comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ation_pending

The token error kept only the description, and the poll looked for the code in
it. Auth0's description ("User has yet to authorize device code.") does not
contain the code, so the first poll ended the login. TokenError keeps both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ot open

When the browser opens, login prints one line. When it cannot be opened, it
prints the URL and a --device hint and keeps waiting, instead of failing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment on lines +67 to +74
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, "/")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one for before merge: discover trusts prm.resource + authorization_servers as-is, so a non-qdrant --endpoint could get a real token sent back to it. wdyt about ignoring the doc unless resource == hosts.Resource() (rfc 9728 §3.3)? rest looks good 🙌

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants