From 077855e1f5592d59060446e5e4d171508f91f7a4 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Wed, 17 Jun 2026 09:25:46 +0000 Subject: [PATCH] fix(auth): request configured scopes in device authorization flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestDeviceCode only sent client_id (+ optional resource) to the device authorization endpoint, omitting the scope parameter. The configured scopes include offline_access, so dropping them meant the device (--no-browser / headless) flow never requested offline_access and the provider returned no refresh token. Without a refresh token, store.refresh() short-circuits on "no refresh token available", so headless/SSH sessions can never auto-refresh and must re-run the full device flow every time the 1h access token expires. The browser (authorization-code) flow already forwards c.cfg.Scopes via buildAuthURL; mirror that in requestDeviceCode. scope is a valid optional parameter on the device authorization request (RFC 8628 §3.1). Verified against an Authentik provider: with this change the device flow returns a refresh token and the store's auto-refresh path works, so headless logins no longer re-authenticate on every expiry. 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 --- pkg/auth/client/client.go | 1 + pkg/auth/client/client_test.go | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/pkg/auth/client/client.go b/pkg/auth/client/client.go index 92b16720..1de56f8e 100644 --- a/pkg/auth/client/client.go +++ b/pkg/auth/client/client.go @@ -600,6 +600,7 @@ func (c *client) exchangeCode(ctx context.Context, code, verifier, redirectURI s func (c *client) requestDeviceCode(ctx context.Context) (*deviceAuthResponse, error) { data := url.Values{ "client_id": {c.cfg.ClientID}, + "scope": {strings.Join(c.cfg.Scopes, " ")}, } if c.cfg.Resource != "" { data.Set("resource", c.cfg.Resource) diff --git a/pkg/auth/client/client_test.go b/pkg/auth/client/client_test.go index 3e2850b0..ac9b6596 100644 --- a/pkg/auth/client/client_test.go +++ b/pkg/auth/client/client_test.go @@ -339,3 +339,65 @@ func TestClientCredentialsSurfacesTokenEndpointError(t *testing.T) { t.Fatalf("expected 401 in error, got: %v", err) } } + +func TestRequestDeviceCodeRequestsOfflineAccessScope(t *testing.T) { + t.Parallel() + + var gotForm url.Values + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": server.URL, + "token_endpoint": server.URL + "/token", + "device_authorization_endpoint": server.URL + "/device", + }) + case "/device": + if err := r.ParseForm(); err != nil { + t.Errorf("parsing form: %v", err) + } + + gotForm = r.PostForm + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "device-code", + "user_code": "USER-CODE", + "verification_uri": server.URL + "/activate", + "expires_in": 600, + "interval": 5, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + c, ok := New(logrus.New(), Config{ + IssuerURL: server.URL, + ClientID: "panda-proxy", + }).(*client) + if !ok { + t.Fatal("New did not return *client") + } + + if err := c.discover(context.Background()); err != nil { + t.Fatalf("discover failed: %v", err) + } + + if _, err := c.requestDeviceCode(context.Background()); err != nil { + t.Fatalf("requestDeviceCode failed: %v", err) + } + + if got := gotForm.Get("client_id"); got != "panda-proxy" { + t.Errorf("form client_id = %q, want %q", got, "panda-proxy") + } + + // offline_access must be requested in the device authorization flow so the + // provider issues a refresh token; without it, headless sessions cannot + // auto-refresh and must re-run the full device flow on every expiry. + if scope := gotForm.Get("scope"); !strings.Contains(scope, "offline_access") { + t.Fatalf("device authorization scope must include offline_access, got %q", scope) + } +}