Skip to content

Request offline_access so browser logins get a refresh token - #29

Merged
ndenny merged 2 commits into
developfrom
feat/refresh-tokens
Oct 19, 2026
Merged

ndenny merged 2 commits into
developfrom
feat/refresh-tokens

Conversation

@ndenny

@ndenny ndenny commented Oct 16, 2026

Copy link
Copy Markdown
Member

Summary

The CLI already has the code for refresh tokens: RefreshTokenSource tries a cached refresh token before falling back to a browser login, and TokenHandler caches any refresh token the token endpoint returns. It never received one, though, because the browser login asked only for openid profile email, and Auth0 issues a refresh token only when offline_access is 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:

  • adds offline_access to the browser-login scopes (cli/apiconfig.go);
  • logs at debug level why a refresh was rejected before falling back to the browser login (oauth/refresh.go). The logged text is the token endpoint's error response, not the token;
  • corrects the cache comment in 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 RefreshTokenSource and 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: prod auth.apimetrics.io, which beta shares, QC qc-auth.apimetrics.io, and dev:

  1. APIs → https://client.apimetrics.io → Settings → Allow Offline Access: on.
  2. Applications → the CLI application → Advanced Settings → Grant Types → Refresh Token: ticked.
  3. Same application → Refresh Token Rotation: on, with a short reuse interval in case two CLI commands refresh at the same moment.
  4. Same application → Refresh Token Expiration: set an inactivity lifetime and an absolute lifetime. These set how often users have to log in through the browser.

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 login or 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 with grant_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 requests offline_access.
  • go test ./... passes.

🤖 Generated with Claude Code

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>

Copilot AI left a comment

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.

🔵 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_access to 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.go to 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

Copilot AI left a comment

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.

🟢 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

neil-gall
neil-gall previously approved these changes Oct 19, 2026
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>
@ndenny
ndenny merged commit ad0c94c into develop Oct 19, 2026
1 check passed
@ndenny
ndenny deleted the feat/refresh-tokens branch October 19, 2026 15:22
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