Skip to content

cloud login: add browser-based OAuth login behind --oauth - #6226

Draft
dgzlopes wants to merge 5 commits into
masterfrom
add-oauth-login
Draft

cloud login: add browser-based OAuth login behind --oauth#6226
dgzlopes wants to merge 5 commits into
masterfrom
add-oauth-login

Conversation

@dgzlopes

@dgzlopes dgzlopes commented Jul 30, 2026

Copy link
Copy Markdown
Member

What?

k6 cloud login --oauth authenticates in the browser instead of asking the user to paste a token.

It runs the OAuth PKCE flow served by the Grafana Assistant app on each stack, as gcx does:

  • k6 opens the stack's CLI auth page
  • receives the authorization code on a short-lived callback server on localhost
  • exchanges it for a Grafana access token.
Screen.Recording.2026-07-30.at.10.38.27.mov

Why?

It is a much better experience than having to find tokens, copy them, etc.

Problems

Once the user lands in assistant, the copy mentions gcx.

We could make a small change in their app to be able to change that text on demand.

Checklist

  • I have performed a self-review of my code.
  • I have commented on my code, particularly in hard-to-understand areas.
  • I have added tests for my changes.
  • I have run linter and tests locally (make check) and all pass.

Checklist: Documentation (only for k6 maintainers and if relevant)

Please do not merge this PR until the following items are filled out.

  • I have added the correct milestone and labels to the PR.
  • I have updated the release notes: link
  • I have updated or added an issue to the k6-documentation: grafana/k6-docs#NUMBER if applicable
  • I have updated or added an issue to the TypeScript definitions: grafana/k6-DefinitelyTyped#NUMBER if applicable

Related PR(s)/Issue(s)

`k6 cloud login --oauth` authenticates in the browser instead of asking
the user to paste a token. It runs the OAuth PKCE flow served by the
Grafana Assistant app on each stack, as gcx does: k6 opens the stack's
CLI auth page, receives the authorization code on a short-lived callback
server on localhost, and exchanges it for a Grafana access token.

That access token is used once, to read the user's k6 API token through
the k6 app plugin's resource API, and is then discarded. Only the k6 API
token is persisted, so login needs no token refresh or revocation
machinery, and the credential k6 stores is the same one `--token` takes
by hand. The token is read rather than regenerated, so logging in on one
machine does not invalidate a token already in use on another.

--stack is required, since the stack serves the login page; the user is
prompted for it when the flag is omitted.

The flow depends on the Grafana Assistant app being installed on the
stack, and on the user holding its tokens.gcx:access permission.
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 08:22 — with GitHub Actions Inactive
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 08:24 — with GitHub Actions Inactive
Self-review fixes on the browser login:

- Fail the login when the CSRF state cannot be generated, instead of
  falling back to an empty string. An empty expected state compared equal
  to an absent state parameter, so the check passed and the protection was
  silently disabled; the comment claimed the opposite. The state and PKCE
  secrets now live in a per-login session value generated once, up front,
  and the handler rejects an empty expected state outright.
- Refuse redirects to local addresses during the token exchange. The
  endpoint check exempts them so the flow can be pointed at a development
  server, but a redirect target is entirely server-chosen, so exempting it
  there would let a redirect hand the bearer token to any process
  listening on the machine.
- Reject --token combined with --oauth rather than silently ignoring the
  token.
- Report a login that timed out as such, rather than as "context deadline
  exceeded".
- Compare the stack the browser reports against the requested one after
  normalising both, so a trailing slash or different casing no longer
  looks like a different stack.
- Drop the untested fallback that re-tried the token read against the
  stack itself. It never ran, and removing it means the access token goes
  to exactly one host.
- Size the error channel for both of its senders, so the serving goroutine
  cannot block forever on a full channel.

Tests cover the happy path, state mismatch and absent state, the vacuous
empty-state case, untrusted endpoints in the callback and in the exchange
response, denied logins, control-character stripping, callback replay,
cancellation, port release, and the token read.
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 08:42 — with GitHub Actions Inactive
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 08:44 — with GitHub Actions Inactive
CI lint caught 17 issues the local golangci-lint could not report, as the
Homebrew build crashes on the Go 1.26 toolchain. Installing the version
pinned in .golangci.yml reproduces CI exactly; the package now lints
clean.

Substantive fixes:

- Replace os.Hostname as the device label. The os package is forbidden
  outside k6's own wrappers, and there is no wrapper for this, so the
  caller now supplies a DeviceName and cloud login passes k6/<version>.
  The token is still identifiable in the user's device list, though less
  precisely than a hostname would be.
- Take a context in OpenInBrowser and use exec.CommandContext, per noctx.
  The opener hands the URL over and exits immediately, so the browser
  still outlives it.
- Strip cancellation from the shutdown context with context.WithoutCancel
  rather than starting from Background. The login's context is usually
  already cancelled by then, but its values should still apply. This also
  removes the nolint that contextcheck reported as unused, because the
  directive was on the function rather than the call site it fired on.
- Drop os.Stderr as the default progress writer; messages are discarded
  when no writer is set. Every caller in k6 passes one.
- Tag Result.AccessToken as json:"-" so the access token cannot be
  serialized by accident (gosec G117).
- Parse the callback page template per call instead of holding it in a
  package-level variable (gochecknoglobals), keeping html/template so the
  server-supplied error stays contextually escaped.
- Explain the two G704 SSRF findings and the three G204 subprocess
  findings rather than suppressing them silently: each URL is allowlisted
  before use, and every redirect target is re-checked.
- Check the progress-message write errors, dropping them deliberately.

Test fixes:

- Use assert instead of require on the fake server's goroutines. FailNow
  may only be called from the test goroutine, so a failing require there
  was undefined behaviour rather than a clean failure.
- Use t.Context() instead of context.Background() (usetesting).
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 08:56 — with GitHub Actions Inactive
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 08:58 — with GitHub Actions Inactive
The two failures a user is most likely to hit — the Grafana Assistant app
not being installed on the stack, and the user lacking permission to mint
CLI tokens — were surfacing as a bare HTTP status.

Reading the k6 API token now maps 401 and 403 to the gcx User role, and
404 to the app not being installed. The status code alone cannot tell
these apart with certainty, so the server's own message is kept alongside
the hint rather than replaced by it. An account with no k6 token now says
so, and points at opening the k6 app once to have one created.

The timeout carries the same hint. When the login page itself fails to
load, nothing reaches k6 at all, so the browser shows the error and k6
just waits — making the timeout the only place those causes can be
mentioned.
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 12:54 — with GitHub Actions Inactive
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 12:55 — with GitHub Actions Inactive
The local-address exemption in URL validation exists so the flow can be
pointed at a development server, but it was also being applied to the
endpoint and instanceEndpoint values that arrive from the browser. A page
that induced a login could therefore name a local address, and k6 would
POST the authorization code and the PKCE verifier to whatever was
listening on it — handing over exactly the secret that makes an
intercepted code useless.

The exemption now depends on the flow's own target: a login against a
local stack may be sent to local endpoints, a login against Grafana Cloud
may not. The exchange response's api_endpoint is held to the same rule.

Reverting the check makes the new test fail with "token exchange failed
with status 404", which is the vulnerability rather than a near miss: the
request was sent, and a process on the developer's machine answered it.

Also clip the server-supplied detail in a failed token read to 256
characters. It was interpolated into an error at up to the 1 MiB read
limit, letting a server flood the terminal.
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 13:04 — with GitHub Actions Inactive
@dgzlopes
dgzlopes temporarily deployed to azure-trusted-signing July 30, 2026 13:06 — with GitHub Actions Inactive
@joanlopez
joanlopez self-requested a review July 30, 2026 14:13
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.

1 participant