Files
sure/app/controllers/oidc_accounts_controller.rb
Josh 97391a27ba Share family accounts with invited members on every sign-up path (#2650)
* 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
2026-07-14 01:39:26 +02:00

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