Files
sure/app/models/oidc_identity.rb
plind 6402f1dd08 fix(sso): preserve user-edited name across OIDC logins (#1777)
OidcIdentity#sync_user_attributes! runs on every SSO sign-in and
overwrote user.first_name / user.last_name with whatever the IdP sent,
because the precedence was `auth.info.* || user.*` — the IdP always
won when it supplied a value. A user who edited their first name to
"Adam" inside Sure had it reset to the IdP value "Ben" on the next
login, while the last name only "stuck" when the IdP happened not to
return a last_name (#1103).

Swap the precedence to `user.* || auth.info.*` so the IdP fills only
when Sure has nothing on file (first link or admin-blanked field).
Edits inside Sure are then authoritative for every subsequent login.
The audit copy on the OidcIdentity record itself is unchanged, so the
IdP-reported name is still available for debugging.

Closes #1103.

Co-authored-by: plind-junior <plind-junior@users.noreply.github.com>
2026-05-12 21:55:22 +02:00

115 lines
3.9 KiB
Ruby

class OidcIdentity < ApplicationRecord
belongs_to :user
validates :provider, presence: true
validates :uid, presence: true, uniqueness: { scope: :provider }
validates :user_id, presence: true
# Update the last authenticated timestamp
def record_authentication!
update!(last_authenticated_at: Time.current)
end
# Sync user attributes from IdP on each login
# Updates stored identity info and syncs name to user (not email - that's identity)
def sync_user_attributes!(auth)
# Extract groups from claims (various common claim names)
groups = extract_groups(auth)
# Update stored identity info with latest from IdP
update!(info: {
email: auth.info&.email,
name: auth.info&.name,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name,
groups: groups
})
# Sync name to user only when Sure has nothing on file (first link, or an
# admin blanked the field). Edits made inside Sure must survive subsequent
# SSO logins — previously the IdP value won unconditionally and clobbered
# any manually-edited name on every login (#1103).
user.update!(
first_name: user.first_name.presence || auth.info&.first_name.presence,
last_name: user.last_name.presence || auth.info&.last_name.presence
)
# Apply role mapping based on group membership
apply_role_mapping!(groups)
end
# Extract groups from various common IdP claim formats
def extract_groups(auth)
# Try various common group claim locations
groups = auth.extra&.raw_info&.groups ||
auth.extra&.raw_info&.[]("groups") ||
auth.extra&.raw_info&.[]("Group") ||
auth.info&.groups ||
auth.extra&.raw_info&.[]("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups") ||
auth.extra&.raw_info&.[]("cognito:groups") ||
[]
# Normalize to array of strings
Array(groups).map(&:to_s)
end
# Apply role mapping based on IdP group membership
def apply_role_mapping!(groups)
config = provider_config
return unless config.present?
role_mapping = config.dig(:settings, :role_mapping) || config.dig(:settings, "role_mapping")
return unless role_mapping.present?
# Check roles in order of precedence (highest to lowest)
%w[super_admin admin member].each do |role|
mapped_groups = role_mapping[role] || role_mapping[role.to_sym] || []
mapped_groups = Array(mapped_groups)
# Check if user is in any of the mapped groups
if mapped_groups.include?("*") || (mapped_groups & groups).any?
# Only update if different to avoid unnecessary writes
user.update!(role: role) unless user.role == role
Rails.logger.info("[SSO] Applied role mapping: user_id=#{user.id} role=#{role} groups=#{groups}")
return
end
end
end
# Extract and store relevant info from OmniAuth auth hash
def self.create_from_omniauth(auth, user)
# Extract issuer from OIDC auth response if available
issuer = auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss")
create!(
user: user,
provider: auth.provider,
uid: auth.uid,
issuer: issuer,
info: {
email: auth.info&.email,
name: auth.info&.name,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name
},
last_authenticated_at: Time.current
)
end
# Find the configured provider for this identity
def provider_config
AuthConfig.sso_providers&.find { |p| p[:name] == provider || p[:id] == provider }
end
# Validate that the stored issuer matches the configured provider's issuer
# Returns true if valid, false if mismatch (security concern)
def issuer_matches_config?
return true if issuer.blank? # Backward compatibility for old records
config = provider_config
return true if config.blank? || config[:issuer].blank? # No config to validate against
issuer == config[:issuer]
end
end