Repository navigation
Request offline_access so browser logins get a refresh token - #29
Conversation
The CLI already caches refresh tokens and tries them before falling back to a browser login, but it never asked Auth0 for one: the scopes were "openid profile email", so every expired access token meant logging in again. Add offline_access to the scopes. Also log at debug level why a refresh was rejected before falling back, and correct the cache comment, which assumed the server never returns a new refresh token (it does under refresh token rotation). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It modifies authentication/OAuth token-scope behavior affecting all interactive users and depends on external Auth0 tenant configuration, which warrants final human review despite the clean, well-tested diff.
No open issues 🎉
What changed in this PR
This PR enables automatic silent token renewal for interactive (browser) logins. The CLI already contained refresh-token machinery (RefreshTokenSource tries a cached refresh token before falling back to a browser login, and TokenHandler caches any returned refresh token), but it never received a refresh token because the browser login requested only openid profile email. Auth0 issues a refresh token only when offline_access is also requested, so every expired access token forced a new browser login. The change adds that scope and improves related diagnostics/comments, fitting into the existing oauth auth-code flow without affecting service-account (client-credentials) auth.
Changes:
- Add
offline_accessto the browser-login scopes so Auth0 returns a refresh token. - Add a debug-level log in
RefreshTokenSource.Token()explaining why a refresh was rejected before falling back to browser login. - Correct the refresh-token cache comment in
oauth/oauth.goto reflect refresh-token rotation, and add unit tests for the refresh/fallback paths and the scope configuration.
| File | Description |
|---|---|
cli/apiconfig.go |
Adds offline_access to default browser-login scopes with an explanatory comment. |
oauth/refresh.go |
Adds a debug log describing the refresh-token rejection before browser-login fallback. |
oauth/oauth.go |
Corrects the cache comment to account for refresh-token rotation (comment-only). |
oauth/refresh_test.go |
New tests: refresh token is exchanged/rotated without browser login; rejected token falls back once and keeps the returned refresh token. |
cli/serviceaccount_test.go |
New test asserting the default browser-login profile requests offline_access. |
I verified that cli.LogDebug matches the variadic call signature (cli/logger.go:16), that RefreshTokenSource's fields used in the tests exist (oauth/refresh.go:13-34), and that the space-separated scope string remains consistent with the existing strings.Split(params["scopes"], ",") / strings.Join(..., " ") handling (oauth/authcode.go:301,439,448). Service-account and client-credentials auth are unaffected since service accounts replace the profile auth entirely (cli/apiconfig.go:137-140). The error logged on fallback is the token endpoint's error body, not the token itself (oauth/request.go:48). I found no code issues.
Review effort: Balanced
There was a problem hiding this comment.
🟢 Approval recommended
The functional delta is a single standard OAuth scope addition that round-trips correctly, is backward compatible, leaves service-account auth untouched, and is covered by new passing tests.
No open issues 🎉
Review effort: Balanced
Resolve conflict in cli/serviceaccount_test.go by keeping both the offline_access scope test and develop's device-code login tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
The CLI already has the code for refresh tokens:
RefreshTokenSourcetries a cached refresh token before falling back to a browser login, andTokenHandlercaches any refresh token the token endpoint returns. It never received one, though, because the browser login asked only foropenid profile email, and Auth0 issues a refresh token only whenoffline_accessis requested. In practice every expired access token sent the user back through the browser, even though the README says credentials are "refreshed automatically".This PR:
offline_accessto the browser-login scopes (cli/apiconfig.go);oauth/refresh.go). The logged text is the token endpoint's error response, not the token;oauth/oauth.go. It said a refreshed token never carries a new refresh token, which is wrong under refresh token rotation. The code already handled rotation correctly; only the comment was wrong.Service-account (client credentials) auth is unchanged: it doesn't go through
RefreshTokenSourceand still requests no scope.Auth0 configuration required
Without these settings Auth0 ignores
offline_access, and the CLI behaves exactly as before (no refresh token, browser login on expiry). Each tenant needs them: prodauth.apimetrics.io, which beta shares, QCqc-auth.apimetrics.io, and dev:https://client.apimetrics.io→ Settings → Allow Offline Access: on.Rollout
Existing cached logins have no refresh token. Each user gets one the next time they log in through the browser, whether by running
apimetrics loginor when their current access token expires. After that, renewal happens without the browser until the refresh token expires or is revoked.Testing
oauth/refresh_test.go(new): a cached refresh token is exchanged withgrant_type=refresh_token, the rotated refresh token is returned, and the browser-login source is not called. A rejected refresh token falls back to that source exactly once and keeps the refresh token it returns.cli/serviceaccount_test.go: the default browser-login profile requestsoffline_access.go test ./...passes.🤖 Generated with Claude Code