Skip to content

fix: return access_denied to device token poll when consent is denied#4111

Open
petebacondarwin wants to merge 1 commit into
ory:masterfrom
petebacondarwin:fix/device-flow-access-denied-on-deny
Open

fix: return access_denied to device token poll when consent is denied#4111
petebacondarwin wants to merge 1 commit into
ory:masterfrom
petebacondarwin:fix/device-flow-access-denied-on-deny

Conversation

@petebacondarwin

@petebacondarwin petebacondarwin commented Jun 12, 2026

Copy link
Copy Markdown

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_pending until the device_code expires, instead of returning access_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_denied when the device session is in the UserCodeRejected state (handler/rfc8628/token_handler.go):

if state == fosite.UserCodeRejected {
    return nil, fosite.ErrAccessDenied
}

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.verifyConsent returns the access_denied RFC error and discards the flow, and performOAuth2DeviceVerificationFlow writes that error to the browser and returns before reaching any SetUserCodeState call. The device session is left at UserCodeUnused, so the poll keeps returning authorization_pending.

Change

  • consent/strategy_default.go: verifyConsent now 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: when performOAuth2DeviceVerificationFlow sees a denied device flow, it marks the associated device-code session as UserCodeRejected (via a new markDeviceUserCodeRejected helper that mirrors the existing accept-path transition). The polling client then receives access_denied on 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 returns access_denied. Verified that the test fails without the fix (the poll returns authorization_pending) and passes with it. The existing device-flow and consent suites continue to pass.

Checklist

  • I have read the contributing guidelines.
  • I have referenced an issue containing the design document if my change introduces a new feature.
  • I have added tests that prove my fix is effective or that my feature works.
  • I confirm that this pull request does not address a security vulnerability. If this pull request addresses a security vulnerability, I confirm that I got the approval (please contact security@ory.sh) from the maintainers to push the changes.

Summary by CodeRabbit

  • Bug Fixes

    • Improved device code flow error handling: when a user denies consent, polling clients now receive an immediate access_denied response instead of waiting for the request to expire.
  • Tests

    • Added test coverage for device flow consent rejection scenarios.

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>
@petebacondarwin petebacondarwin requested review from a team and aeneasr as code owners June 12, 2026 20:48
@CLAassistant

CLAassistant commented Jun 12, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

When 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 access_denied instead of waiting for expiration. The consent handler returns the flow alongside the error, the device handler transitions the session to UserCodeRejected, and an end-to-end test validates the behavior.

Changes

Device flow consent denial handling

Layer / File(s) Summary
Consent error handling with flow return
consent/strategy_default.go
verifyConsent now returns the decoded flow alongside the RFC error when consent is denied, enabling device-flow callers to access DeviceCodeRequestID and reject the device session.
Device session state transition on consent denial
oauth2/handler.go
When device verification encounters a consent-denied error with a valid device code request ID, the handler transitions the device-code session to UserCodeRejected via a new persistence helper before returning the error response.
Test coverage for consent rejection in device flow
oauth2/oauth2_device_code_test.go
A new test case verifies that rejecting consent returns HTTP 403 with error=access_denied on the next device-code token poll; a new helper pollDeviceToken performs single RFC 8628 device-code token polls for precise response assertions.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix: returning access_denied to device token polling when consent is denied, which directly addresses the core issue.
Description check ✅ Passed The description thoroughly covers related issues, root cause analysis, detailed changes, test coverage, and a complete checklist, fully meeting the template requirements.
Linked Issues check ✅ Passed The PR directly addresses all coding requirements from #4110: (1) returning flow alongside error in verifyConsent, (2) transitioning device session to UserCodeRejected in performOAuth2DeviceVerificationFlow, and (3) providing end-to-end regression test for the denied consent path.
Out of Scope Changes check ✅ Passed All changes are narrowly scoped to the device flow consent denial issue: verifyConsent return value change, device session rejection helper, and targeted test coverage with no unrelated modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
oauth2/handler.go (1)

829-845: 📐 Maintainability & Code Quality | 💤 Low value

Consider 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

📥 Commits

Reviewing files that changed from the base of the PR and between 1b1063b and 5cbdddf.

📒 Files selected for processing (3)
  • consent/strategy_default.go
  • oauth2/handler.go
  • oauth2/oauth2_device_code_test.go

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.

Device flow: denied consent does not return access_denied to the polling client (UserCodeRejected never set)

2 participants