mirror of
https://github.com/we-promise/sure.git
synced 2026-07-20 08:45:22 +00:00
* fix: share family accounts with invited members on every sign-up path A user who joined an existing family through an invitation was added to the family but saw none of its accounts, even when the family's default sharing is "share with all members". Only Invitation#accept_for created the AccountShare records; the OIDC just-in-time sign-up, invite-token registration, and mobile SSO onboarding paths all skipped it, so invitees landed in the family with an empty account list. Signups routed into an invite-only default family had the same gap. Extract the sharing into Family#auto_share_existing_accounts_with, the single entry point for "a member just joined, apply the family's sharing policy". It honors default_account_sharing, only shares with a persisted member of the family, grants read_only to guests and read_write to everyone else, excludes accounts the user already owns, and is idempotent. accept_for and every sign-up path call it, so no current or future join path can reintroduce the empty-account bug or share accounts across families. Also wrap the two SSO account-creation paths (OIDC JIT and mobile SSO) in a transaction covering the user save, invitation acceptance, account sharing, and identity creation, so a failure partway through can no longer leave a half-onboarded user with no linked identity. * address review: guest read_only symmetric in auto_share_with_family!, load-bearing guard comment, mobile SSO private-no-op test
209 lines
6.9 KiB
Ruby
209 lines
6.9 KiB
Ruby
class OidcAccountsController < ApplicationController
|
|
skip_authentication only: [ :link, :create_link, :new_user, :create_user ]
|
|
layout "auth"
|
|
|
|
def link
|
|
# Check if there's pending OIDC auth in session
|
|
@pending_auth = session[:pending_oidc_auth]
|
|
|
|
if @pending_auth.nil?
|
|
redirect_to new_session_path, alert: t(".no_pending_oidc")
|
|
return
|
|
end
|
|
|
|
@email = @pending_auth["email"]
|
|
@user_exists = User.exists?(email: @email) if @email.present?
|
|
|
|
# Check for a pending invitation for this email
|
|
@pending_invitation = Invitation.pending.find_by(email: @email) if @email.present?
|
|
|
|
# Determine whether we should offer JIT account creation for this
|
|
# pending auth, based on JIT mode and allowed domains.
|
|
@allow_account_creation = @pending_invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email))
|
|
end
|
|
|
|
def create_link
|
|
@pending_auth = session[:pending_oidc_auth]
|
|
|
|
if @pending_auth.nil?
|
|
redirect_to new_session_path, alert: t(".no_pending_oidc")
|
|
return
|
|
end
|
|
|
|
# Verify user's password to confirm identity
|
|
user = User.authenticate_by(email: params[:email], password: params[:password])
|
|
|
|
if user
|
|
# Create the OIDC identity link
|
|
oidc_identity = OidcIdentity.create_from_omniauth(
|
|
build_auth_hash(@pending_auth),
|
|
user
|
|
)
|
|
|
|
# Log account linking
|
|
SsoAuditLog.log_link!(
|
|
user: user,
|
|
provider: @pending_auth["provider"],
|
|
request: request
|
|
)
|
|
|
|
# Clear pending auth from session
|
|
session.delete(:pending_oidc_auth)
|
|
|
|
if user.otp_required?
|
|
session[:mfa_user_id] = user.id
|
|
redirect_to verify_mfa_path
|
|
else
|
|
@session = create_session_for(user)
|
|
notice = if accept_pending_invitation_for(user)
|
|
t("invitations.accept_choice.joined_household")
|
|
else
|
|
t("sessions.openid_connect.account_linked", provider: @pending_auth["provider"])
|
|
end
|
|
redirect_to root_path, notice: notice
|
|
end
|
|
else
|
|
@email = params[:email]
|
|
@user_exists = User.exists?(email: @email) if @email.present?
|
|
flash.now[:alert] = "Invalid email or password"
|
|
render :link, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def new_user
|
|
# Check if there's pending OIDC auth in session
|
|
@pending_auth = session[:pending_oidc_auth]
|
|
|
|
if @pending_auth.nil?
|
|
redirect_to new_session_path, alert: t(".no_pending_oidc")
|
|
return
|
|
end
|
|
|
|
# Pre-fill user details from OIDC provider
|
|
@user = User.new(
|
|
email: @pending_auth["email"],
|
|
first_name: @pending_auth["first_name"],
|
|
last_name: @pending_auth["last_name"]
|
|
)
|
|
end
|
|
|
|
def create_user
|
|
@pending_auth = session[:pending_oidc_auth]
|
|
|
|
if @pending_auth.nil?
|
|
redirect_to new_session_path, alert: t(".no_pending_oidc")
|
|
return
|
|
end
|
|
|
|
email = @pending_auth["email"]
|
|
|
|
# Check for a pending invitation for this email
|
|
invitation = Invitation.pending.find_by(email: email)
|
|
|
|
# Respect global JIT configuration: in link_only mode or when the email
|
|
# domain is not allowed, block JIT account creation—unless there's a
|
|
# pending invitation for this user.
|
|
unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email))
|
|
redirect_to new_session_path, alert: t(".account_creation_disabled")
|
|
return
|
|
end
|
|
|
|
# Create SSO-only user without local password.
|
|
# Security: JIT users should NOT have password_digest set to prevent
|
|
# chained authentication attacks where SSO users gain local login access
|
|
# via password reset.
|
|
# Allow user to edit first_name and last_name from the form, but email comes from OIDC
|
|
user_params = params.fetch(:user, {}).permit(:first_name, :last_name)
|
|
@user = User.new(
|
|
email: email,
|
|
first_name: user_params[:first_name].presence || @pending_auth["first_name"],
|
|
last_name: user_params[:last_name].presence || @pending_auth["last_name"],
|
|
skip_password_validation: true
|
|
)
|
|
|
|
if invitation.present?
|
|
# Accept the pending invitation: join the existing family
|
|
@user.family_id = invitation.family_id
|
|
@user.role = invitation.role
|
|
else
|
|
# Create new family for this user
|
|
@user.family = Family.new
|
|
|
|
# Use provider-configured default role, or fall back to admin for family creators
|
|
# First user of an instance always becomes super_admin regardless of provider config
|
|
provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] }
|
|
provider_default_role = provider_config&.dig(:settings, :default_role)
|
|
@user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin)
|
|
end
|
|
|
|
identity = nil
|
|
account_created = false
|
|
|
|
begin
|
|
account_created = ActiveRecord::Base.transaction do
|
|
unless @user.save
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
# Mark invitation as accepted if one was used
|
|
invitation&.update!(accepted_at: Time.current)
|
|
|
|
# Joining an existing family via invitation must honor the family's
|
|
# default sharing policy, matching Invitation#accept_for. Without this a
|
|
# JIT SSO invitee lands in the family but sees none of its accounts.
|
|
@user.family.auto_share_existing_accounts_with(@user) if invitation.present?
|
|
|
|
# Create the OIDC (or other SSO) identity
|
|
identity = OidcIdentity.create_from_omniauth(
|
|
build_auth_hash(@pending_auth),
|
|
@user
|
|
)
|
|
|
|
true
|
|
end
|
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
|
|
# Expected persistence failures (e.g. a duplicate identity) roll the whole
|
|
# onboarding back and re-render the form. Unexpected errors propagate so
|
|
# they surface instead of being hidden as a form validation message.
|
|
@user.errors.add(:base, e.message)
|
|
end
|
|
|
|
if account_created
|
|
# Only log JIT account creation if identity was successfully created
|
|
if identity&.persisted?
|
|
SsoAuditLog.log_jit_account_created!(
|
|
user: @user,
|
|
provider: @pending_auth["provider"],
|
|
request: request
|
|
)
|
|
end
|
|
|
|
# Clear pending auth from session
|
|
session.delete(:pending_oidc_auth)
|
|
|
|
@session = create_session_for(@user)
|
|
notice = if invitation.present?
|
|
t("invitations.accept_choice.joined_household")
|
|
elsif accept_pending_invitation_for(@user)
|
|
t("invitations.accept_choice.joined_household")
|
|
else
|
|
t(".account_created")
|
|
end
|
|
redirect_to root_path, notice: notice
|
|
else
|
|
render :new_user, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
# Convert pending auth hash to OmniAuth-like structure
|
|
def build_auth_hash(pending_auth)
|
|
OpenStruct.new(
|
|
provider: pending_auth["provider"],
|
|
uid: pending_auth["uid"],
|
|
info: OpenStruct.new(pending_auth.slice("email", "name", "first_name", "last_name"))
|
|
)
|
|
end
|
|
end
|