Skip to content

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

Description

@petebacondarwin

Preflight checklist

  • I could not find a solution in the existing issues, docs, nor discussions.
  • I agree to follow this project's Code of Conduct.
  • I have read and am following this repository's Contribution Guidelines.

Describe the bug

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. The expected access_denied is never returned, so the polling client (e.g. a CLI) appears to hang for the full device_code lifetime instead of failing fast.

The denial is surfaced to the browser/user-agent that completed the verification, but it is never propagated to the device that is polling the token endpoint.

Root cause

fosite already supports the terminal rejected state. In handler/rfc8628/token_handler.go, the device token handler returns access_denied when the device session is in the UserCodeRejected state:

state := req.GetUserCodeState()
if state == fosite.UserCodeUnused {
    return nil, fosite.ErrAuthorizationPending
}
if state == fosite.UserCodeRejected {
    return nil, fosite.ErrAccessDenied
}

However, nothing in Hydra ever transitions a device session into UserCodeRejected. The only writer of the state is the accept path in oauth2/handler.go:

req.SetUserCodeState(fosite.UserCodeAccepted)

On the deny path, consent/strategy_default.go::verifyConsent detects the denied consent and returns the access_denied RFC error while discarding the flow:

if f.ConsentError.IsError() {
    f.ConsentError.SetDefaults(flow.ConsentRequestDeniedErrorName)
    return nil, errors.WithStack(f.ConsentError.ToRFCError())
}

performOAuth2DeviceVerificationFlow then writes that error to the browser and returns before reaching the SetUserCodeState call. The device session is left at UserCodeUnused, so the token poll keeps returning authorization_pending until the device code expires.

Reproducing the bug

  1. Start a device flow: POST /oauth2/device/auth to obtain a device_code and user_code.
  2. In a browser, complete the verification flow (enter the user code, log in) up to the consent screen.
  3. Reject consent: PUT /admin/oauth2/auth/requests/consent/reject?consent_challenge=....
  4. Poll the token endpoint with the original device_code (grant_type=urn:ietf:params:oauth:grant-type:device_code).

Expected: the poll returns access_denied (RFC 8628 §3.5).

Actual: the poll returns authorization_pending repeatedly until expires_in elapses, after which it returns expired_token. access_denied is never returned.

Affected versions

Confirmed on v25.4.0 and on master (the deny path in performOAuth2DeviceVerificationFlow returns before any SetUserCodeState call in both).

Proposed fix

Propagate the denial to the device session so the polling client receives access_denied on its next poll. Have verifyConsent return the flow alongside the error (the auth-code caller discards the flow on error, so this is safe), then in performOAuth2DeviceVerificationFlow, when the flow is a device flow that was denied, transition the device session to UserCodeRejected — mirroring the existing UserCodeAccepted transition.

I have a patch with an end-to-end regression test (deny consent → token poll returns access_denied) and will open a PR shortly.

Server logs

(none relevant — no error is logged; the device session simply stays in the pending state)

Server configuration

N/A — reproducible with default configuration once the device grant is enabled for a client.

Context

The OAuth 2.0 Device Authorization Grant landed in v25.4.0 (#3851). This appears to be an unfinished edge of that work: the three-state user_code_state model (active / accepted / rejected) was introduced, but only the accepted transition was wired up.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backlogbugSomething is not working.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions