Extract OidcUserConsent.record! upsert for shared consent

The consent record is unique on user+application and shared between the browser
authorization flow and device-flow approval. Centralize the upsert in
OidcUserConsent.record!(merge:) so a narrower device request unions its scopes
into any existing grant (merge: true) rather than overwriting it and wiping
stored claims, while the browser consent screen records exactly the approved
scopes (merge: false). scopes is now nil-safe for not-yet-saved records.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
This commit is contained in:
Dan Milne
2026-07-19 13:59:44 +10:00
co-authored by Claude Opus 4.8
parent 7eddea8356
commit e3f0bd4cab
3 changed files with 70 additions and 11 deletions
@@ -87,14 +87,14 @@ class DeviceAuthorizationsController < ApplicationController
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!
# merge: true — the consent record is shared with the browser flow, so a
# narrower device request must not shrink previously granted scopes or wipe
# stored claims.
OidcUserConsent.record!(
user: user,
application: device_code.application,
scopes: granted_scopes(device_code),
merge: true
)
end
end
+22 -2
View File
@@ -8,9 +8,29 @@ class OidcUserConsent < ApplicationRecord
before_validation :set_granted_at, on: :create
before_validation :set_sid, on: :create
# Parse scopes_granted into an array
# Upsert a user's consent for an application. The record is unique on
# user+application and shared across the browser and device flows.
#
# merge: false (the browser consent screen) records exactly the scopes the user
# just approved. merge: true (device approval) unions the scopes into any
# existing grant and leaves stored claims untouched, so a narrower device
# request can never shrink a prior grant or wipe its claims. claims_requests is
# written only when supplied (nil = keep whatever is stored, defaulting to {}
# for a brand-new record).
def self.record!(user:, application:, scopes:, claims_requests: nil, merge: false)
consent = find_or_initialize_by(user: user, application: application)
incoming = Array(scopes)
consent.scopes = merge ? (consent.scopes | incoming) : incoming
consent.claims_requests = claims_requests unless claims_requests.nil?
consent.claims_requests ||= {}
consent.granted_at = Time.current
consent.save!
consent
end
# Parse scopes_granted into an array (nil-safe for not-yet-saved records).
def scopes
scopes_granted.split(" ")
scopes_granted.to_s.split(" ")
end
# Set scopes from an array