Skip to content

fix(auth): request configured scopes in device authorization flow#231

Open
lodekeeper wants to merge 1 commit into
ethpandaops:masterfrom
lodekeeper:fix/device-flow-offline-access-scope
Open

fix(auth): request configured scopes in device authorization flow#231
lodekeeper wants to merge 1 commit into
ethpandaops:masterfrom
lodekeeper:fix/device-flow-offline-access-scope

Conversation

@lodekeeper

Copy link
Copy Markdown

Problem

The device authorization (--no-browser / headless, auto-selected over SSH) login never receives a refresh token, so headless/SSH sessions must re-run the entire interactive device flow every time the ~1h access token expires.

Root cause: requestDeviceCode builds the device authorization request with only client_id (+ optional resource) and omits the scope parameter:

data := url.Values{
    "client_id": {c.cfg.ClientID},
}

The default scopes set in New include offline_access:

cfg.Scopes = []string{"openid", "email", "groups", "offline_access"}

…but since the device flow never forwards them, offline_access is never requested and the provider returns an access-token-only response. The browser (authorization-code) flow is unaffected — buildAuthURL already forwards c.cfg.Scopes.

Downstream impact: store.refresh() bails out without a refresh token:

if prior == nil || prior.RefreshToken == "" {
    return nil, fmt.Errorf("no refresh token available")
}

So the store auto-refresh path can never run for headless logins — every expiry forces a fresh interactive device flow.

Fix

Forward the configured scopes in the device authorization request, mirroring buildAuthURL:

data := url.Values{
    "client_id": {c.cfg.ClientID},
    "scope":     {strings.Join(c.cfg.Scopes, " ")},
}

scope is a valid (optional) parameter on the device authorization request per RFC 8628 §3.1.

Verification

  • Added TestRequestDeviceCodeRequestsOfflineAccessScope (mirrors the existing TestBuildAuthURLOmitsResourceWhenEmpty). It fails without the fix (scope empty) and passes with it.
  • Verified end-to-end against an Authentik provider: with this change the device flow returns a refresh token, and the store auto-refresh path then keeps the session alive without re-running the device flow.
  • go test ./pkg/auth/client/... ./pkg/auth/store/... passes; gofmt clean.

🤖 Generated with AI assistance

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 <noreply@anthropic.com>
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