Add RFC 8628 device authorization grant support - #6647
Open
reyortiz3 wants to merge 2 commits into
Open
Conversation
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)
reyortiz3
requested review from
ChrisJBurns,
JAORMX,
jhrozek,
rdimitrov and
tgrunnagle
as code owners
September 11, 2026 16:12
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
DeviceRequest/DeviceRequestStatusand theDeviceCodeStorageinterface (storage/types.go), embedded intoStoragealongside
PendingAuthorizationStorage;MemoryStorageandRedisStorageimplementations, each keyed by both
device_code(canonical) anduser_code(secondary index), TTL-bound viaDefaultDeviceRequestTTL(10minutes).
ErrInvalidStatedistinguishes "already authorized/denied" fromnot-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 newDeviceFlowEnabledconfig flag is set.urn:ietf:params:oauth:grant-type:device_codetoken grant(
server/deviceflow) — afosite.TokenEndpointHandlerenforcing RFC 8628§3.5 polling semantics (
authorization_pending,slow_down,expired_token,access_denied), wired intobuildProvideralongside theexisting token-exchange/JWT-bearer factories, issuing both an access token
and (when the client supports
refresh_token) a refresh token, andconsuming the device_code so it cannot be redeemed twice.
device_authorization_endpointand the grant type areadvertised only when
DeviceFlowEnabledis set.RunConfig.DeviceFlowEnabled(off by default, matching everyother 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 callMarkDeviceRequestAuthorized/MarkDeviceRequestDeniedfrom a real upstream-IdP login — the integrationtest 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 existingprecedent for
IdentityFromTokenConfig(config lands inpkg/authserverfirst, 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 llmwork, tracked separately). Not linked withFixessince the verification-page follow-up is still needed before that issue is
fully addressed.
Type of change
Test plan
Unit tests (
task test)Linting (
task lint-fix)pkg/authserver/storage: store/load by both codes, duplicateuser-code/device-code rejection, not-found, TTL expiry, authorize/deny
transitions and
ErrInvalidStateon a repeat transition, last-polled-atupdate, 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 theconfigured interval →
slow_down, authorized → access + refresh tokensissued 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) andTestIntegration_DeviceAuthorizationEndpoint_Disabled(route not mountedwhen 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-repotask testfailsonly on a pre-existing, already-broken, untracked file
(
pkg/authserver/integration_threeupstreams_repro_test.go, someone'sin-progress work never committed) — confirmed independently broken with or
without this PR's changes present, and not part of this PR's diff.
Changes
pkg/authserver/storage/types.go,memory.go,redis.go,redis_keys.goDeviceCodeStorageinterface + both backend implementationspkg/authserver/server/deviceflow/*.gopkg/authserver/server/handlers/device_authorization.goPOST /oauth/device_authorizationpkg/authserver/server/handlers/handler.gopkg/authserver/server/handlers/discovery.gopkg/authserver/server/provider.go,server_impl.goDeviceFlowEnabled/DeviceCodeIntervalplumbing, factory registrationpkg/authserver/config.go,runner/embeddedauthserver.goRunConfig.DeviceFlowEnabledpkg/oauthproto/constants.go,discovery.gopkg/authserver/storage/mocks/mock_storage.goDoes 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 bydefault). 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):
DeviceCodeStorageinterface plus memory and Redisimplementations and unit tests.
POST /oauth/device_authorizationhandler, rate-limited like
/oauth/register, plus discovery metadata.fosite.TokenEndpointHandlerforgrant_type=urn:ietf:params:oauth:grant-type:device_code.GET /oauth/device,reusing the existing
authorize.go/callback.goupstream-loginmachinery; binds the resolved identity to the matching device-code row.
DeviceFlowEnabledflag (in this PR);an architecture-doc addition is still pending. CRD/operator exposure is
an explicit sibling follow-up, matching the
IdentityFromTokenConfigprecedent.
Special notes for reviewers
(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.ErrTokenExpiredwas deliberately NOT reused for theexpired_tokencase: itsErrorFieldis"invalid_token"(RFC 6750§3.1's bearer-token vocabulary), not RFC 8628 §3.5's
"expired_token".A local
ErrExpiredTokensentinel is defined indeviceflow/errors.goinstead, to emit the wire-correct error code.
fosite.TokenEndpointHandlerin this repo's pinned fosite (v0.49.0) hasno separate
CanHandleRequestmethod — onlyCanHandleTokenEndpointRequest,HandleTokenEndpointRequest,CanSkipClientAuth, andPopulateTokenEndpointResponse.HandleTokenEndpointRequest,not
PopulateTokenEndpointResponse— confirmed fosite calls the formerexactly once per token request before the latter, so this is the correct
single-use enforcement point.
refresh_tokengranttype (no
offline_access-scope gating yet) — a reasonable futurerefinement, not required for this PR's scope.
integration_threeupstreams_repro_test.gomentioned aboveis not part of this diff (never
git add-ed) — flagging it only so CIfailures on
mainaren't confused with this PR if that file is evercommitted elsewhere.
Generated with Claude Code