Fixes from review of the device flow / introspection / DCR work:
Introspection over-disclosure (RFC 7662):
- Restrict introspection to authorized callers — the client a token was issued
to, or a resource server registered (resource_identifiers) to serve the token's
bound RFC 8707 audience. Unauthorized callers get {active:false}, disclosing
nothing, instead of any confidential client reading any token.
- Scope-gate disclosed claims: username only with the email scope, groups only
with the groups scope (mirrors userinfo). ADR 0005.
Tokens minted for revoked users:
- Re-check application.user_allowed?(user) at mint time in the device, refresh,
and authorization-code grants (covers app-active, user-active, group
membership). A user deactivated or removed from the allowed group between
approval and the token request is refused with access_denied. The refresh
check runs before rotation so a denied refresh has no side effects.
Device authorization hardening:
- Require confidential clients to authenticate, and require PKCE (code_challenge)
up front for clients that require it, so an intercepted device_code plus a
known public client_id cannot redeem tokens.
- Merge (not overwrite) the shared consent record on device approval.
Tests: new oidc_introspection_test and oidc_mint_authorization_test; existing
PKCE/claims tests updated to grant app access (they created ungrouped apps and
relied on the token endpoint not checking authorization). Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
93 lines
2.9 KiB
Ruby
93 lines
2.9 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).
|
|
|
|
# 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
|