Skip to content

fix(auth): treat refresh_expires_in=0 as no-expiry for offline tokens#40

Merged
hbrombeer merged 1 commit intomainfrom
fix/offline-refresh-token-no-expiry
May 6, 2026
Merged

fix(auth): treat refresh_expires_in=0 as no-expiry for offline tokens#40
hbrombeer merged 1 commit intomainfrom
fix/offline-refresh-token-no-expiry

Conversation

@hbrombeer
Copy link
Copy Markdown
Member

Login requests offline_access and Keycloak issues a real offline (never-expiring) refresh token, but signals 'no expiry' on the wire by setting refresh_expires_in: 0. The CLI was naively doing now + 0 = now, so every offline token was treated as 'expired the moment after login'. New RefreshExpiryFromSeconds helper + IsRefreshAlive predicate fix the writer + reader sides; tests cover the boundaries.

The CLI requests `offline_access` on login, and Keycloak honours it —
the issued refresh token has `typ: Offline` and never expires. But
Keycloak's wire format signals "no expiry" by setting
`refresh_expires_in: 0`, and the CLI was naively computing
`RefreshExpiresAt = time.Now().Add(0 * time.Second)`. That stored the
expiry as the wall-clock time of login itself, so the very next
`grounds <cmd>` (or even `grounds doctor`) saw RefreshExpiresAt < now
and demanded `grounds login`. The actual offline token sat unused in
the keychain the whole time.

Fix:
- New `RefreshExpiryFromSeconds(seconds int) time.Time` helper that
  returns the zero `time.Time` when seconds <= 0 (the canonical
  "no expiry" sentinel) and `time.Now().Add(seconds * time.Second)`
  otherwise.
- New `(*Credentials).IsRefreshAlive()` predicate: zero time means
  alive; otherwise compare to wall clock.
- All three writers (login flow's CredentialsFromToken, source.go's
  inline refresh, doctor.go's inline refresh) go through the new
  helper.
- All three readers (source.go and doctor.go gates, plus doctor.go's
  status summary) go through IsRefreshAlive. Doctor's "valid for X"
  line now prints "no expiry (offline token)" when applicable.

Tests cover the boundary cases: seconds=0 → zero time, negative →
zero time, positive → finite future, IsRefreshAlive matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hbrombeer hbrombeer merged commit 81355c7 into main May 6, 2026
5 checks passed
@hbrombeer hbrombeer deleted the fix/offline-refresh-token-no-expiry branch May 6, 2026 10:46
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