From 21db0442569e5d4cd728e731b84da30cb1bf7724 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Thu, 30 Apr 2026 08:53:24 +0200 Subject: [PATCH 1/2] feat(auth): request offline_access scope in device flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `offline_access` in the device-authz scope param, Keycloak issues a refresh token bound to the SSO session-idle window (realm default 30 minutes). After a short idle period the CLI's next refresh fails and `FileTokenSource` deletes the credentials, forcing a `grounds login` re-auth — a bad UX for an interactive CLI. Adding `offline_access` upgrades the refresh token to an offline token whose lifetime is governed by the offline-session settings (default 30 days), matching the behaviour every developer expects from `gh`, `kubectl oidc-login`, etc. Verified live: same client + same user - scope=openid → refresh_expires_in=1800 - scope=openid offline_access → refresh_expires_in=0 (offline) `offline_access` is a default optional realm scope in Keycloak, so no per-client wiring is needed in keycloak-clients.ts. --- internal/auth/device.go | 9 +++++++-- internal/auth/device_test.go | 9 ++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/auth/device.go b/internal/auth/device.go index 3673fed..127a9e9 100644 --- a/internal/auth/device.go +++ b/internal/auth/device.go @@ -51,8 +51,13 @@ func (d *DeviceClient) StartDevice(ctx context.Context) (*DeviceCodeResponse, er return nil, fmt.Errorf("pkce: %w", err) } body := url.Values{ - "client_id": {d.ClientID}, - "scope": {"openid profile email"}, + "client_id": {d.ClientID}, + // `offline_access` upgrades the refresh_token to an "offline + // token" with a 30-day default TTL, decoupled from the SSO + // session-idle window (Keycloak default 30 minutes). Without + // it a user who runs `grounds login` once in the morning + // would have to re-authenticate after lunch. + "scope": {"openid profile email offline_access"}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}, } diff --git a/internal/auth/device_test.go b/internal/auth/device_test.go index b82822b..adde605 100644 --- a/internal/auth/device_test.go +++ b/internal/auth/device_test.go @@ -11,7 +11,7 @@ import ( ) func TestStartDevice(t *testing.T) { - var gotChallenge, gotMethod string + var gotChallenge, gotMethod, gotScope string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/auth/device") { t.Fatalf("path = %s", r.URL.Path) @@ -19,6 +19,7 @@ func TestStartDevice(t *testing.T) { r.ParseForm() gotChallenge = r.Form.Get("code_challenge") gotMethod = r.Form.Get("code_challenge_method") + gotScope = r.Form.Get("scope") json.NewEncoder(w).Encode(DeviceCodeResponse{ DeviceCode: "dc", UserCode: "ABCD-EFGH", @@ -48,6 +49,12 @@ func TestStartDevice(t *testing.T) { if res.CodeVerifier == "" { t.Error("CodeVerifier should be populated for PollToken") } + // offline_access keeps the refresh token alive past the SSO + // session-idle window so the CLI doesn't ask for a re-login + // after a few minutes of inactivity. + if !strings.Contains(gotScope, "offline_access") { + t.Errorf("scope = %q, want it to contain offline_access", gotScope) + } } func TestPollToken_Success(t *testing.T) { From 454df0c612e1266843b3567955f9477d59ae0b06 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Thu, 30 Apr 2026 08:55:35 +0200 Subject: [PATCH 2/2] fix(release): drop unsupported version_prefix; expose release-please version output --- .github/workflows/release.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b93dfa6..7cceb0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ jobs: outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} steps: - uses: googleapis/release-please-action@v5 id: release @@ -70,10 +71,8 @@ jobs: SENTRY_URL: https://glitch.grounds.gg/ with: environment: production - # release-please tag_name is e.g. `v0.1.7`; sentry-go's - # Release field is set from internal/version.Version which - # ldflags-baked equals goreleaser's .Version (no `v`). Strip - # the prefix here so the marker matches what the binary - # reports at runtime. - version: ${{ needs.release-please.outputs.tag_name }} - version_prefix: v + # release-please's `version` output is the bare semver + # (e.g. 0.1.8); the binary's runtime release tag also has + # no `v` prefix. Use `version` not `tag_name` so the + # GlitchTip marker matches what the SDK reports at runtime. + version: ${{ needs.release-please.outputs.version }}