Skip to content

Add RFC 8628 device authorization grant support - #6647

Open
reyortiz3 wants to merge 2 commits into
mainfrom
add-device-code-storage
Open

Add RFC 8628 device authorization grant support#6647
reyortiz3 wants to merge 2 commits into
mainfrom
add-device-code-storage

Conversation

@reyortiz3

@reyortiz3 reyortiz3 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Headless MCP clients (remote dev hosts, CI-adjacent operator boxes, and
similar non-desktop environments) cannot complete the browser-based
authorization-code callback this embedded auth server currently requires to
obtain a user grant. This PR adds OAuth 2.0 Device Authorization Grant
support (RFC 8628): such a client can obtain a device/user code pair, hand
the user code to a human for out-of-band verification on any browser, and
poll for a token without ever receiving a redirect itself.

Everything needed to actually mint a token is here, end to end:

  • StorageDeviceRequest/DeviceRequestStatus and the
    DeviceCodeStorage interface (storage/types.go), embedded into Storage
    alongside PendingAuthorizationStorage; MemoryStorage and RedisStorage
    implementations, each keyed by both device_code (canonical) and
    user_code (secondary index), TTL-bound via DefaultDeviceRequestTTL (10
    minutes). ErrInvalidState distinguishes "already authorized/denied" from
    not-found/expired, so a stale verification-page resubmission can never
    clobber a request the token endpoint already consumed.
  • POST /oauth/device_authorization (handlers/device_authorization.go)
    — issues the device_code/user_code pair per RFC 8628 §3.1/§3.2, rate
    limited like /oauth/register, only mounted when the new
    DeviceFlowEnabled config flag is set.
  • The urn:ietf:params:oauth:grant-type:device_code token grant
    (server/deviceflow) — a fosite.TokenEndpointHandler enforcing RFC 8628
    §3.5 polling semantics (authorization_pending, slow_down,
    expired_token, access_denied), wired into buildProvider alongside the
    existing token-exchange/JWT-bearer factories, issuing both an access token
    and (when the client supports refresh_token) a refresh token, and
    consuming the device_code so it cannot be redeemed twice.
  • Discoverydevice_authorization_endpoint and the grant type are
    advertised only when DeviceFlowEnabled is set.
  • ConfigRunConfig.DeviceFlowEnabled (off by default, matching every
    other optional grant in this codebase), threaded through
    runner/embeddedauthserver.go.

Explicitly not in this PR: the human-facing verification page
(GET /oauth/device) that would actually call MarkDeviceRequestAuthorized/
MarkDeviceRequestDenied from a real upstream-IdP login — the integration
test drives that transition directly against storage to prove the rest of
the pipeline end to end. CRD/operator exposure
(cmd/thv-operator/api/v1beta1) is also out of scope, matching the existing
precedent for IdentityFromTokenConfig (config lands in pkg/authserver
first, operator surface follows separately).

Related: stacklok/stacklok-enterprise-platform#4127 (the enterprise
distribution issue that motivated this — Connector Gateway's embedded auth
server is this package; AI Gateway's half of that issue is unrelated
client-side thv llm work, tracked separately). Not linked with Fixes
since the verification-page follow-up is still needed before that issue is
fully addressed.

Type of change

  • New feature

Test plan

  • Unit tests (task test)

  • Linting (task lint-fix)

  • pkg/authserver/storage: store/load by both codes, duplicate
    user-code/device-code rejection, not-found, TTL expiry, authorize/deny
    transitions and ErrInvalidState on a repeat transition, last-polled-at
    update, delete removing both indexes, concurrent same-user-code store.

  • pkg/authserver/server/deviceflow: pending → authorization_pending,
    denied → access_denied, unknown/wrong-client device_code →
    invalid_grant, expired → expired_token, polling faster than the
    configured interval → slow_down, authorized → access + refresh tokens
    issued with the stored scopes/audience, and a second redemption of the
    same device_code fails (single-use).

  • pkg/authserver/integration_test.go: TestIntegration_DeviceFlow_FullHappyPath
    (device_authorization → simulate operator authorization via storage →
    poll token endpoint → success, then re-poll → invalid_grant) and
    TestIntegration_DeviceAuthorizationEndpoint_Disabled (route not mounted
    when the feature flag is off).

Verified independently (not just by the implementing session): full-repo
go build ./... clean; go test -race ./pkg/authserver/... ./pkg/oauthproto/...
green across every package; the two new integration tests re-run explicitly
with -count=1 (cache bypassed) both pass. A full-repo task test fails
only on a pre-existing, already-broken, untracked file
(pkg/authserver/integration_threeupstreams_repro_test.go, someone's
in-progress work never committed) — confirmed independently broken with or
without this PR's changes present, and not part of this PR's diff.

Changes

File Change
pkg/authserver/storage/types.go, memory.go, redis.go, redis_keys.go DeviceCodeStorage interface + both backend implementations
pkg/authserver/server/deviceflow/*.go New package: the device-code token grant handler
pkg/authserver/server/handlers/device_authorization.go POST /oauth/device_authorization
pkg/authserver/server/handlers/handler.go Rate limiter, polling-interval config, conditional route registration
pkg/authserver/server/handlers/discovery.go Advertises the grant/endpoint when enabled
pkg/authserver/server/provider.go, server_impl.go DeviceFlowEnabled/DeviceCodeInterval plumbing, factory registration
pkg/authserver/config.go, runner/embeddedauthserver.go RunConfig.DeviceFlowEnabled
pkg/oauthproto/constants.go, discovery.go Grant-type constant, discovery metadata field
pkg/authserver/storage/mocks/mock_storage.go Regenerated

Does this introduce a user-facing change?

Yes, but dormant by default: operators can opt in to RFC 8628 device-flow
support for the embedded auth server via DeviceFlowEnabled (off by
default). Until the follow-up verification-page PR lands, an authorized
device request can only be produced by calling storage directly (as the
integration test does) — there is no way for a real end user to complete
the human-verification step yet, so this flag has no usable effect for real
traffic until that follow-up ships.

Implementation plan

Approved implementation plan

This PR was planned as a 5-step sequence, landed here as ONE PR per an
explicit decision to not split upstream work across multiple PRs (all steps
below are in this PR except step 4):

  1. Storage layerDeviceCodeStorage interface plus memory and Redis
    implementations and unit tests.
  2. Device authorization endpointPOST /oauth/device_authorization
    handler, rate-limited like /oauth/register, plus discovery metadata.
  3. Token endpoint grant handler — a fosite.TokenEndpointHandler for
    grant_type=urn:ietf:params:oauth:grant-type:device_code.
  4. Verification UI + binding (NOT in this PR) — GET /oauth/device,
    reusing the existing authorize.go/callback.go upstream-login
    machinery; binds the resolved identity to the matching device-code row.
  5. Config + docs — the opt-in DeviceFlowEnabled flag (in this PR);
    an architecture-doc addition is still pending. CRD/operator exposure is
    an explicit sibling follow-up, matching the IdentityFromTokenConfig
    precedent.

Special notes for reviewers

  • PR size: this is one PR covering storage + endpoint + grant handler
    (well over the usual 400-line/10-file guideline) by explicit decision —
    splitting a working end-to-end grant across several upstream PRs would
    leave intermediate PRs shipping dead code with no caller, which is worse
    than a larger, but fully coherent, single review.
  • fosite.ErrTokenExpired was deliberately NOT reused for the
    expired_token case: its ErrorField is "invalid_token" (RFC 6750
    §3.1's bearer-token vocabulary), not RFC 8628 §3.5's "expired_token".
    A local ErrExpiredToken sentinel is defined in deviceflow/errors.go
    instead, to emit the wire-correct error code.
  • fosite.TokenEndpointHandler in this repo's pinned fosite (v0.49.0) has
    no separate CanHandleRequest method — only CanHandleTokenEndpointRequest,
    HandleTokenEndpointRequest, CanSkipClientAuth, and
    PopulateTokenEndpointResponse.
  • Device-code consumption (delete) happens in HandleTokenEndpointRequest,
    not PopulateTokenEndpointResponse — confirmed fosite calls the former
    exactly once per token request before the latter, so this is the correct
    single-use enforcement point.
  • Refresh-token issuance gates solely on the client's refresh_token grant
    type (no offline_access-scope gating yet) — a reasonable future
    refinement, not required for this PR's scope.
  • The untracked integration_threeupstreams_repro_test.go mentioned above
    is not part of this diff (never git add-ed) — flagging it only so CI
    failures on main aren't confused with this PR if that file is ever
    committed elsewhere.

Generated with Claude Code

Headless MCP clients (remote dev hosts, CI-adjacent operator boxes)
cannot complete the browser-based authorization-code callback this
auth server currently requires. RFC 8628 (Device Authorization
Grant) lets such a client obtain a device/user code, hand the user
code to a human for out-of-band verification, and poll for a token
without ever receiving a redirect itself.

This is the storage foundation only, mirroring the existing
PendingAuthorizationStorage shape:

- DeviceRequest/DeviceRequestStatus and the DeviceCodeStorage
  interface (types.go), embedded into Storage alongside
  PendingAuthorizationStorage.
- MemoryStorage and RedisStorage implementations, each keyed by both
  device_code (canonical) and user_code (secondary index), TTL-bound
  via DefaultDeviceRequestTTL.
- ErrInvalidState distinguishes "already authorized/denied" from
  not-found/expired, so a stale verification-page resubmission can
  never clobber a request the token endpoint already consumed.

No HTTP endpoints, token-endpoint grant handler, or config/CRD
surface yet -- those land in follow-up PRs once this storage layer
is in.

Generated with [Claude Code](https://claude.com/claude-code)
@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Sep 11, 2026
@reyortiz3 reyortiz3 changed the title Add device-code storage for RFC 8628 device grant Add RFC 8628 device authorization grant support Sep 11, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 11, 2026
@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.74098% with 86 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.98%. Comparing base (41dec70) to head (d836aae).

Files with missing lines Patch % Lines
...authserver/server/handlers/device_authorization.go 62.22% 34 Missing ⚠️
pkg/authserver/storage/redis.go 85.60% 19 Missing ⚠️
pkg/authserver/server/deviceflow/handler.go 85.36% 12 Missing ⚠️
pkg/authserver/storage/memory.go 90.16% 12 Missing ⚠️
pkg/authserver/server/handlers/handler.go 78.57% 3 Missing ⚠️
pkg/authserver/server/deviceflow/factory.go 85.71% 2 Missing ⚠️
pkg/authserver/server/handlers/discovery.go 50.00% 2 Missing ⚠️
pkg/authserver/server_impl.go 80.00% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #6647    +/-   ##
========================================
  Coverage   78.98%   78.98%            
========================================
  Files         782      785     +3     
  Lines       78067    78520   +453     
========================================
+ Hits        61660    62020   +360     
- Misses      16402    16495    +93     
  Partials        5        5            

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant