Preflight checklist
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
- Start a device flow:
POST /oauth2/device/auth to obtain a device_code and user_code.
- In a browser, complete the verification flow (enter the user code, log in) up to the consent screen.
- Reject consent:
PUT /admin/oauth2/auth/requests/consent/reject?consent_challenge=....
- 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.
Preflight checklist
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_pendinguntil thedevice_codeexpires. The expectedaccess_deniedis never returned, so the polling client (e.g. a CLI) appears to hang for the fulldevice_codelifetime 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 returnsaccess_deniedwhen the device session is in theUserCodeRejectedstate:However, nothing in Hydra ever transitions a device session into
UserCodeRejected. The only writer of the state is the accept path inoauth2/handler.go:On the deny path,
consent/strategy_default.go::verifyConsentdetects the denied consent and returns theaccess_deniedRFC error while discarding the flow:performOAuth2DeviceVerificationFlowthen writes that error to the browser and returns before reaching theSetUserCodeStatecall. The device session is left atUserCodeUnused, so the token poll keeps returningauthorization_pendinguntil the device code expires.Reproducing the bug
POST /oauth2/device/authto obtain adevice_codeanduser_code.PUT /admin/oauth2/auth/requests/consent/reject?consent_challenge=....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_pendingrepeatedly untilexpires_inelapses, after which it returnsexpired_token.access_deniedis never returned.Affected versions
Confirmed on v25.4.0 and on master (the deny path in
performOAuth2DeviceVerificationFlowreturns before anySetUserCodeStatecall in both).Proposed fix
Propagate the denial to the device session so the polling client receives
access_deniedon its next poll. HaveverifyConsentreturn the flow alongside the error (the auth-code caller discards the flow on error, so this is safe), then inperformOAuth2DeviceVerificationFlow, when the flow is a device flow that was denied, transition the device session toUserCodeRejected— mirroring the existingUserCodeAcceptedtransition.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
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_statemodel (active/accepted/rejected) was introduced, but only theacceptedtransition was wired up.