Files
clinch/app/controllers/device_authorizations_controller.rb
T
Dan MilneandClaude Opus 4.8 71ee301dd2 Device flow: replay-revocation, user_code CSPRNG, /device rate limit
More device-flow security-review fixes.

Replay-revocation on device_code redemption (was: destroy on use):
- Redemption now marks the code redeemed_at and links the issued access/refresh
  tokens back to it (new oidc_device_code_id FKs, on_delete: :nullify) instead of
  destroying the row. A replayed redeemed code is detected as reuse — revoking
  every descended token and returning a distinguishable "already been used"
  invalid_grant rather than the generic "Invalid device_code" — mirroring the
  authorization-code reuse semantics (RFC 6749 §4.1.2). The device_code FK is
  carried forward across refresh rotation so revocation reaches the whole chain.
  Redeemed codes are reaped by the existing expiry cleanup sweep.

user_code CSPRNG (RFC 8628 §6.1):
- The 8-char user_code is a credential (type it + Approve mints tokens), so draw
  it from SecureRandom instead of Ruby's global Mersenne Twister (Array#sample).

Rate-limit the /device verification endpoint (RFC 8628 §5.1):
- Add the app's standard 10/min limit on show + verify so a signed-in user can't
  brute-force the short code space to deny or hijack a pending authorization.

Also carried in this commit (other agent's working-tree change):
- Give each OidcController rate_limit a distinct name: so frequent device polling
  on the token endpoint no longer shares one counter with (and 429s) unrelated
  token/refresh/revoke/introspect calls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
2026-07-19 13:38:40 +10:00

101 lines
3.3 KiB
Ruby

# User-facing side of the OAuth 2.0 Device Authorization Grant (RFC 8628 §3.3).
#
# The CLI/agent sends the human here (GET /device) with the short user_code it
# was issued. This controller is authenticated, so an unauthenticated visitor is
# bounced through /signin (with their passkey) and returned here afterwards via
# session[:return_to_after_authenticating]. On POST /device the signed-in user
# approves or denies; approval attaches them to the device code and records
# consent so the token endpoint can mint tokens.
class DeviceAuthorizationsController < ApplicationController
# Browser form endpoint — keep CSRF protection on (do NOT skip it).
# RFC 8628 §5.1: rate-limit user_code entry so a signed-in user cannot brute
# force the short code space to deny or hijack another user's pending
# authorization during its ~10 minute window. Covers both the lookup (show) and
# the state-changing submit (verify).
rate_limit to: 10, within: 1.minute, only: [:show, :verify], with: -> {
render plain: "Too many attempts. Try again later.", status: :too_many_requests
}
# GET /device?user_code=WDJB-MJHT
def show
@user_code = params[:user_code].to_s
@device_code = OidcDeviceCode.find_by_user_code(@user_code) if @user_code.present?
if @device_code.nil?
@state = @user_code.present? ? :not_found : :prompt
elsif @device_code.expired?
@state = :expired
elsif !@device_code.pending?
@state = :already_handled
else
@state = :confirm
@application = @device_code.application
@scopes = granted_scopes(@device_code)
end
render :show
end
# POST /device
def verify
@device_code = OidcDeviceCode.find_by_user_code(params[:user_code].to_s)
if @device_code.nil?
@state = :not_found
return render :result
end
if @device_code.expired?
@state = :expired
return render :result
end
unless @device_code.pending?
@state = :already_handled
return render :result
end
@application = @device_code.application
if params[:deny].present?
@device_code.deny!
@state = :denied
return render :result
end
# Enforce the same group-based access control as the OIDC authorize flow.
unless @application.user_allowed?(Current.user)
@state = :not_allowed
return render :result
end
record_consent(@device_code, Current.user)
@device_code.approve!(
user: Current.user,
acr: Current.session.acr,
auth_time: Current.session.created_at.to_i
)
@state = :approved
render :result
end
private
def granted_scopes(device_code)
device_code.scope.to_s.split & OidcController::SUPPORTED_SCOPES
end
def record_consent(device_code, user)
consent = OidcUserConsent.find_or_initialize_by(user: user, application: device_code.application)
# Merge into any existing consent instead of overwriting it. The consent
# record is shared with the browser flow (unique on user+application) and
# scopes_granted is treated as a granted superset, so a narrower device
# request must not shrink previously granted scopes or wipe stored claims.
consent.scopes = consent.scopes_granted.to_s.split | granted_scopes(device_code)
consent.claims_requests = {} if consent.new_record?
consent.granted_at = Time.current
consent.save!
end
end