Add device flow verification page and DCR support - #6682
Conversation
The RFC 8628 verification page needs to correlate two short-lived, single-use pieces of state: the upstream IDP login attempt in flight (state -> device_code/user_code plus PKCE/nonce) and, after that login resolves, the identity awaiting an explicit Approve/Deny decision. Add PendingDeviceLoginStorage and PendingDeviceConfirmationStorage, modeled directly on the existing PendingAuthorizationStorage (same shape of problem: short-lived, state-keyed correlation data with no new architectural pattern needed), implemented for both MemoryStorage and RedisStorage and embedded into Storage alongside PendingAuthorizationStorage. The confirmation record is deliberately addressed only by an opaque, unguessable token handed to the browser as a hidden form field -- the resolved identity itself is never round-tripped through the browser as editable form data, closing an obvious tamper vector. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pkg/authserver/server/registration/dcr.go's grant-type allowlist only ever permitted authorization_code/refresh_token, so there was no way to dynamically register a client allowed to use the RFC 8628 device_code grant -- a real client could never be provisioned to use the device flow added in #6647. Add a device-code-only carve-out (grant_types exactly {device_code} or {device_code, refresh_token}) parallel to the existing private_key_jwt carve-out: redirect_uris and response_types are not required, since a device-code client never receives a redirect. Mixing device_code into an authorization_code registration is intentionally not covered -- such a request still needs a redirect_uri/response_type as before. FilterPublicGrantTypes/FilterPublicResponseTypes (used by the CIMD client-metadata-document path) share the same allowlist and now start admitting device_code too; existing tests that asserted device_code was silently filtered out are updated to reflect it being kept. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes the gap left by #6647: /oauth/device_authorization mints a device_code/user_code pair whose verification_uri points at GET /oauth/device, but that route didn't exist -- storage. MarkDeviceRequestAuthorized/MarkDeviceRequestDenied were only ever called by the integration test, directly against storage. Add DeviceVerificationHandler (GET/POST /oauth/device) and DeviceVerificationConfirmHandler (POST /oauth/device/confirm), backed by html/template pages (the first HTML surface in this package -- every other handler writes JSON). The flow: enter/confirm the user_code, redirect to the first configured upstream IDP to authenticate, land on an explicit Approve/Deny confirmation screen, then call MarkDeviceRequestAuthorized or MarkDeviceRequestDenied. The upstream IDP's redirect_uri is fixed per upstream at construction (see upstream.OAuth2Config.RedirectURI / AuthCodeURL), so there is no way to register a distinct callback path for this second login flow. CallbackHandler now dispatches between the OAuth-client authorization_code flow and a device-flow login based on which pending record the callback's state parameter matches, falling back to completeDeviceLogin (device_verification.go) when it doesn't match a PendingAuthorization. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TestIntegration_DeviceFlow_FullHappyPath previously simulated the not-yet-built verification page by calling storage. MarkDeviceRequestAuthorized directly. Now that the page exists, wire the test through a real mock upstream IDP (mockoidc) and the actual HTTP flow: submit user_code, follow the upstream login redirect, land on the shared /oauth/callback endpoint, extract the confirm_token from the rendered confirmation page, and POST the approval -- exercising the exact path a real device-flow client and human now go through end to end, closing the loop the original PR's integration test left open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6682 +/- ##
========================================
Coverage 79.18% 79.19%
========================================
Files 791 794 +3
Lines 79518 80005 +487
========================================
+ Hits 62965 63358 +393
- Misses 16548 16642 +94
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The deny action on the confirmation page always reported success even when the underlying storage write failed, so a user believed they had denied a device whose request could remain authorized. The shared /oauth/callback dispatch also collapsed a genuine storage outage into the same "not found" path used for an expired state, masking backend incidents as client errors. Neither the verification page's user_code guesses nor its confirm_token replay had rate limiting or clickjacking protection, unlike the existing device_authorization endpoint. - Mirror the approve branch's error handling on deny; log at Warn. - Distinguish not-found/expired from real storage errors in the callback dispatch and the user_code re-checks, returning 500 on a genuine backend failure instead of a generic 400. - Rate-limit POST /oauth/device, matching /oauth/device_authorization. - Add anti-framing/no-sniff headers to the verification page renders. - Add tests for deny-then-replay and a concurrent confirm_token race. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Give the three device-flow HTML pages (verify, confirm, result) a proper visual identity instead of unstyled markup: ToolHive/Stacklok brand colors, light/dark support via prefers-color-scheme, pill buttons, and a card layout, with no external font/CDN dependency so the pages still work in air-gapped deployments. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
authorization_code and refresh_token were hardcoded as string literals in dcr.go even though oauthproto already exported constants for both, unlike the device_code entry added alongside them. Use the constants everywhere for consistency. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Split the HTML templates, page-data structs, and render* helpers out of device_verification.go into device_verification_render.go, so the request-handling logic isn't interleaved with ~290 lines of markup and CSS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
Automated review of the device-flow verification page (RFC 8628). Overall the design is solid — PKCE/S256, OIDC nonce handling, IDP mix-up defense, explicit opaque-token-gated consent, and the DCR device_code-only carve-out all check out against spec. Two issues below should block merge: upstream tokens are never persisted for device-flow-authorized sessions, and an upstream access_denied during device-flow login isn't routed to the device-flow cleanup path. The rest are lower-severity/non-blocking.
Device-flow sessions have no session id until confirm time, so the tokens exchanged at login had nowhere to be written yet and were being dropped entirely -- any downstream lookup by session id (token injection, refresh) found nothing for a device-flow session. - Add UpstreamTokens/Synthetic fields to PendingDeviceConfirmation so the handler layer can persist them once a session id exists. - Extract newStoredUpstreamTokens from marshalUpstreamTokensWithTTL so both call sites share the same epoch-time conversion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Persist the upstream tokens captured at login under the device grant's final session id on approval (see the storage commit just before this one) -- previously nothing ever called StoreUpstreamTokens for a device-flow session. - Give handleUpstreamError a device-flow fallback: an upstream IDP error (e.g. the human denies at the upstream login screen) left the DeviceRequest Pending until the device_code itself expired, since nothing marked it denied on that path. - Require exactly one configured upstream at submit time instead of silently defaulting to h.upstreams[0] -- device flow has no client_id/redirect_uri of its own to route per-client, so a multi-upstream deployment would otherwise authenticate against whichever upstream is configured first. - Fix a garbled doc comment on DeviceVerificationHandler. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fields[0][:1] sliced the first byte of a display name, splitting multi-byte UTF-8 sequences and rendering invalid UTF-8/replacement characters in the avatar badge for non-ASCII names. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deviceVerificationLimiter was a single process-wide bucket, unlike the machine-driven, one-shot endpoints it was modeled on (registerLimiter et al). /oauth/device is a human-facing login page a legitimate user retries against, so one caller burning the shared burst starved every other concurrent device-flow login on the server. Add a small per-IP limiter (with inline idle-entry eviction, no background goroutine to manage) and key the gate on RemoteAddr -- deliberately not X-Forwarded-For, which is attacker-controlled without a trusted proxy list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers: upstream tokens retrievable by session id after approval, denial on upstream IDP error, the single-upstream requirement, non-ASCII initials, and per-IP rate-limiter scoping. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
All 6 findings from the previous review round were addressed with dedicated commits and accompanying tests: upstream tokens are now persisted under the device grant's final session id, upstream IDP denial now routes to the device-flow cleanup path, the verification-page rate limiter is now per-IP, the confirm-page initials handle multibyte names correctly, the upstream-selection ambiguity is now an explicit fail-fast, and the garbled doc comment is fixed. LGTM pending CI.
Summary
Follow-up to #6647 (RFC 8628 Device Authorization Grant), which added storage,
the
/oauth/device_authorizationendpoint, and the token-endpoint granthandler but explicitly left two gaps open, both called out in that PR's
description as required before
DeviceFlowEnabledis safe to turn on forreal traffic:
POST /oauth/device_authorizationmints adevice_code/user_codepair whoseverification_uripoints atGET /oauth/device— but that route didn't exist.storage.MarkDeviceRequestAuthorized/MarkDeviceRequestDeniedwere onlyever called by the integration test, directly against storage.
only permitted
authorization_code/refresh_token.This PR closes both gaps, so the device flow is now usable end to end by a
real client and a real human.
What changed:
PendingDeviceLoginStorage/PendingDeviceConfirmationStorage(memory + Redis), modeled on the existing
PendingAuthorizationStorage.The confirmation record is addressed only by an opaque, unguessable token
handed to the browser as a hidden form field — the resolved identity is
never round-tripped through the browser as editable form data.
device_code-only carve-out (parallel to the existingprivate_key_jwtone):redirect_uris/response_typesare not requiredfor a registration whose grant types are exactly
{device_code}or{device_code, refresh_token}.GET/POST /oauth/device(enter/confirm theuser_code, redirect to the first configured upstream IDP) andPOST /oauth/device/confirm(explicit Approve/Deny), rendered viahtml/template— the first HTML surface inpkg/authserver(every otherhandler writes JSON). The three pages (verify, confirm, result) use a
ToolHive/Stacklok-branded palette with light/dark support via
prefers-color-scheme, no external font/CDN dependency (air-gapped/enterprise deployments).
/oauth/callbackis now shared between the OAuth-clientauthorization_codeflow and the device flow's login. An upstream IDP'sredirect_uriis fixed per upstream at construction(
upstream.OAuth2Config.RedirectURI/AuthCodeURL), so there is no way toregister a second callback path for the verification page's login — see
"Special notes" below.
instead of always reporting success; the shared
/oauth/callbackdispatchand the verification page's
user_codere-checks distinguish a genuinestorage error (500) from an expired/not-found request (400) instead of
collapsing both into the same client error;
POST /oauth/deviceis nowrate-limited like
/oauth/device_authorization; the rendered pages sendanti-framing/no-sniff headers.
TestIntegration_DeviceFlow_FullHappyPathnowdrives the real HTTP flow through a mock upstream IDP (mockoidc) instead of
simulating the verification page by calling storage directly.
Related: stacklok/stacklok-enterprise-platform#4127 (tracked in #6647).
Type of change
Test plan
Unit tests (
task test)Linting (
task lint-fix)pkg/authserver/storage: store/load/delete/expiry for both new storagetypes, in both
memory_test.goandredis_test.go.pkg/authserver/server/registration: device_code-only registrationsucceeds without
redirect_uris/response_types; device_code +refresh_token succeeds; device_code with
response_typesset is rejected;device_code mixed with
authorization_codestill requiresredirect_uris(the carve-out only applies to an exact device-code-only grant set).
Updated two pre-existing CIMD tests whose fixtures relied on device_code
being an unsupported (filtered-out) grant type — it's now supported.
pkg/authserver/server/handlers: full happy path (submit code → redirect →callback → confirm page → approve), deny path (including a storage failure
on deny, and deny-then-replay of the confirm token), invalid/expired
user_code, unknown callback state, unknown/reused confirm token
(single-use, including a concurrent-replay race test).
pkg/authserver/integration_test.go:TestIntegration_DeviceFlow_FullHappyPathrewritten to drive the realHTTP flow end to end through a mock OIDC upstream (submit → upstream login
→ shared callback → confirm → poll
/oauth/token→ success → replayfails).
Manual verification: ran the embedded auth server locally (mock upstream,
DCR-registered device-code client) and drove the full flow through a real
browser against the new verification/confirm/result pages.
Verified independently: full-repo
go build ./...clean;go test ./pkg/authserver/...green across every package;task lint-fixclean.Changes
pkg/authserver/storage/types.go,memory.go,redis.go,redis_keys.goPendingDeviceLoginStorage/PendingDeviceConfirmationStorage+ both backend implementationspkg/authserver/server/registration/dcr.godevice_code-only DCR carve-outpkg/authserver/server/handlers/device_verification.gopkg/authserver/server/handlers/callback.go/oauth/callbacknow dispatches between the OAuth-client and device-flow logins, distinguishing storage errors from not-foundpkg/authserver/server/handlers/handler.gopkg/authserver/integration_test.goDoes this introduce a user-facing change?
Yes: with
DeviceFlowEnabledon, a device-flow client can now be fullyauthorized by a real human via the verification page, and a device-flow
client can be dynamically registered via DCR. Both were previously
non-functional stubs (the flag existed but nothing could actually complete
the flow).
Implementation plan
Approved implementation plan
Device Flow Verification Page + DCR Support
Context
PR #6647 (merged,
59ba54dbd) added RFC 8628 Device Authorization Grant support to the embedded OAuthauthorization server (
pkg/authserver/), gated behindDeviceFlowEnabled(off by default). Code review onthat PR (jhrozek) flagged that the feature is not actually usable end-to-end yet:
POST /oauth/device_authorizationmints adevice_code/user_codepair whoseverification_uripointsat
GET /oauth/device— but that route doesn't exist.storage.MarkDeviceRequestAuthorized/MarkDeviceRequestDeniedare only ever called by the integration test, directly against storage.pkg/authserver/server/registration/dcr.go's DCR grant-type allowlist only permitsauthorization_code/refresh_token, so there is also no way to register a client allowed to use thedevice_codegrant.Both gaps were called out in the same PR description update ("
DeviceFlowEnabledmust stay off ... until bothfollow-ups above land") and are being addressed together here, in one PR, per that plan.
Part A: Device verification page
Flow
GET /oauth/device[?user_code=XXXX-XXXX]—DeviceVerificationHandlerrenders an HTML form askingthe user to enter/confirm the
user_code(pre-filled from the query param when the client sentverification_uri_complete).POST /oauth/device—DeviceVerificationSubmitHandlernormalizes the submitted code, loads theDeviceRequestviastorage.LoadDeviceRequestByUserCode(already exists), and validates it isDeviceRequestStatusPendingand unexpired. On success, generates upstream login secrets (reusingnewUpstreamAuthSecretsfromauthorize.go), stores aPendingDeviceLoginrow keyed by that state, andredirects (302) to
h.upstreams[0].Provider.AuthorizationURL(...).reuses the existing
/oauth/callbackendpoint (discovered during implementation; see PR description).CallbackHandlerresolves the identity and stores it server-side behind a fresh opaque token, thenrenders the Approve/Deny confirmation page.
POST /oauth/device/confirm— loads+deletes thePendingDeviceConfirmationby token (single-use),and calls
storage.MarkDeviceRequestAuthorized(approve) orMarkDeviceRequestDenied(deny).Only
h.upstreams[0](the first configured upstream) is used — matchingAuthorizeHandler's first-legbehavior — with no multi-upstream authorization chain support for device flow.
New storage types
PendingDeviceLoginStorageandPendingDeviceConfirmationStorage, modeled onPendingAuthorizationStorage,implemented for both
MemoryStorageandRedisStorage, embedded intoStorage.Part B: DCR support for
device_codeAdd
oauthproto.GrantTypeDeviceCodetoallowedGrantTypes; a device-code-only carve-out (parallel to theexisting
private_key_jwtone) skips theredirect_uris/response_typesrequirements when the grant typesare exactly
{device_code}or{device_code, refresh_token}.Testing
Storage round-trip tests for both new types (memory + Redis); handler tests for the full happy path, deny,
and error paths; DCR validation test cases for device-code-only registration; integration test rewritten to
drive the real HTTP flow through a mock upstream IDP instead of simulating the verification page via direct
storage calls.
Special notes for reviewers
GET /oauth/device/callbackroute. While implementing it, I found thatupstream.OAuth2Provider'sredirect_uri is fixed per upstream at construction time (
upstream.OAuth2Config.RedirectURI, baked intothe
golang.org/x/oauth2config used byAuthCodeURL) — there's no way to pass a different redirect_uriper authorization attempt. So the upstream IDP will always redirect back to whatever
/oauth/callbackwasregistered with it, never a second device-flow-specific path. I unified this by having
CallbackHandlertry
LoadPendingAuthorizationfirst (the existing OAuth-client flow), and fall back toLoadPendingDeviceLoginwhen thestatedoesn't match — seecallback.go'sloadPendingOrCompleteDeviceLogin/tryCompleteDeviceLogin. This avoids requiring operators to register asecond redirect_uri with their upstream IdP, which many IdPs require to be pre-registered exactly.
login, rather than auto-approving immediately — matches how GitHub/Google device flows behave.
upstream, matching
AuthorizeHandler's first-leg behavior. Chained upstream authorization for device flowis out of scope here.
semantics. Kept deliberately simple (a single card, no heavy branding chrome) to stay in the same visual
family as the existing plain OAuth-adjacent pages elsewhere in the codebase (e.g. the CLI's local
loopback callback page in
pkg/auth/oauth/flow.go).storage from the handlers that use it would leave an intermediate PR with dead code and no caller.
🤖 Generated with Claude Code