fix: return access_denied to device token poll when consent is denied#4111
fix: return access_denied to device token poll when consent is denied#4111petebacondarwin wants to merge 1 commit into
Conversation
When a user denies the consent prompt during the OAuth 2.0 Device Authorization Grant (RFC 8628) flow, the device-code token poll kept returning authorization_pending until the device code expired, instead of access_denied. fosite already returns access_denied when the device session is in the UserCodeRejected state, but Hydra never transitioned a session into that state: on consent denial, performOAuth2DeviceVerificationFlow wrote the error to the browser and returned before reaching SetUserCodeState. verifyConsent now returns the flow alongside the error on the consent-denied path (the authorization code caller discards the flow on error, so this is safe), and performOAuth2DeviceVerificationFlow marks the associated device-code session as UserCodeRejected so the polling client receives access_denied on its next poll. Closes ory#4110 Signed-off-by: Pete Bacon Darwin <pbacondarwin@cloudflare.com>
📝 WalkthroughWalkthroughWhen a user denies consent during OAuth 2.0 Device Authorization Grant verification, the flow now propagates that denial to the device-code session so the polling client receives ChangesDevice flow consent denial handling
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
oauth2/handler.go (1)
829-845: 📐 Maintainability & Code Quality | 💤 Low valueConsider wrapping the update in a transaction for consistency.
The accept path (lines 800-819) wraps device-session updates in a transaction. While the rejection helper only performs a single update and atomicity is less critical, wrapping it in
h.r.Transaction(ctx, func...)would maintain consistency with the accept flow and guard against future multi-step rejection logic.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@oauth2/handler.go` around lines 829 - 845, Wrap the state change and storage update in a transaction to mirror the accept flow: call h.r.Transaction(ctx, func(ctx context.Context) error { ... }), move the GetDeviceCodeSessionByRequestID, req.SetUserCodeState(fosite.UserCodeRejected) and UpdateDeviceCodeSessionBySignature calls inside that closure, and return the transaction error from markDeviceUserCodeRejected so the update is atomic and consistent with the accept path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@oauth2/handler.go`:
- Around line 829-845: Wrap the state change and storage update in a transaction
to mirror the accept flow: call h.r.Transaction(ctx, func(ctx context.Context)
error { ... }), move the GetDeviceCodeSessionByRequestID,
req.SetUserCodeState(fosite.UserCodeRejected) and
UpdateDeviceCodeSessionBySignature calls inside that closure, and return the
transaction error from markDeviceUserCodeRejected so the update is atomic and
consistent with the accept path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 315ba058-0156-4d44-9e94-1411f3815504
📒 Files selected for processing (3)
consent/strategy_default.gooauth2/handler.gooauth2/oauth2_device_code_test.go
Related issue(s)
Closes #4110
Summary
When a user denies the consent prompt during the OAuth 2.0 Device Authorization Grant (RFC 8628) flow, the device-code token poll keeps returning
authorization_pendinguntil thedevice_codeexpires, instead of returningaccess_denied. The polling client (e.g. a CLI) therefore appears to hang for the full device-code lifetime even though the user already denied the request in the browser.Root cause
fosite already returns
access_deniedwhen the device session is in theUserCodeRejectedstate (handler/rfc8628/token_handler.go):But Hydra never transitions a device session into
UserCodeRejected. The only writer of the state is the accept path (req.SetUserCodeState(fosite.UserCodeAccepted)). On consent denial,DefaultStrategy.verifyConsentreturns theaccess_deniedRFC error and discards the flow, andperformOAuth2DeviceVerificationFlowwrites that error to the browser and returns before reaching anySetUserCodeStatecall. The device session is left atUserCodeUnused, so the poll keeps returningauthorization_pending.Change
consent/strategy_default.go:verifyConsentnow returns the flow alongside the error on the consent-denied path. The authorization code caller (HandleOAuth2AuthorizationRequest) discards the flow on error, so this is safe; the device caller uses it to identify the device-code session.oauth2/handler.go: whenperformOAuth2DeviceVerificationFlowsees a denied device flow, it marks the associated device-code session asUserCodeRejected(via a newmarkDeviceUserCodeRejectedhelper that mirrors the existing accept-path transition). The polling client then receivesaccess_deniedon its next poll.The change is gated on
f != nil && f.DeviceCodeRequestID != "" && f.ConsentError.IsError(), so it only affects the device flow on an actual consent denial; the authorization code flow is unchanged.Test
Added an end-to-end regression test in
oauth2/oauth2_device_code_test.go(case=polling client receives access_denied when consent is denied): it runs the full device flow, denies consent, and asserts the token poll returnsaccess_denied. Verified that the test fails without the fix (the poll returnsauthorization_pending) and passes with it. The existing device-flow and consent suites continue to pass.Checklist
Summary by CodeRabbit
Bug Fixes
access_deniedresponse instead of waiting for the request to expire.Tests