diff --git a/reporting-app/app/services/member_oidc_provisioner.rb b/reporting-app/app/services/member_oidc_provisioner.rb new file mode 100644 index 00000000..6480becb --- /dev/null +++ b/reporting-app/app/services/member_oidc_provisioner.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Provisions member user accounts from citizen IdP OIDC claims +# +# Creates or updates member users based on claims extracted from OIDC ID tokens. +# Unlike StaffUserProvisioner, this does not use RoleMapper since members do not +# have staff roles. +# +# Usage: +# provisioner = MemberOidcProvisioner.new +# user = provisioner.provision!(claims) +# +# Claims format: +# { +# uid: "unique-id-from-idp", +# email: "member@example.com", +# name: "John Smith" +# } +# +# Behavior: +# - Finds existing users by UID (not email) to handle email changes +# - Updates attributes (name, email) on every login +# - Does NOT set or change role (members have no staff role) +# - Sets mfa_preference to "opt_out" since IdP handles MFA +# +class MemberOidcProvisioner + PROVIDER = "member_oidc" + + # Provision a member user from IdP claims + # + # @param claims [Hash] Claims extracted from OIDC ID token + # @option claims [String] :uid Unique identifier from IdP (required) + # @option claims [String] :email User's email address (required) + # @option claims [String] :name User's full name + # @return [User] The provisioned user record + # @raise [ArgumentError] If required claims (uid, email) are missing + # @raise [ActiveRecord::RecordInvalid] If user validation fails + def provision!(claims) + validate_claims!(claims) + + user = find_or_initialize_user(claims[:uid], claims[:email]) + sync_attributes(user, claims) + user.save! + user + end + + private + + def validate_claims!(claims) + raise ArgumentError, "claims cannot be nil" if claims.nil? + raise ArgumentError, "uid is required" if claims[:uid].blank? + raise ArgumentError, "email is required" if claims[:email].blank? + end + + def find_or_initialize_user(uid, email) + User.find_by(uid: uid) || User.new(uid: uid, email: email, provider: PROVIDER) + end + + def sync_attributes(user, claims) + user.email = claims[:email] + user.full_name = claims[:name] + user.provider = PROVIDER + + # IdP handles MFA; skip app MFA preference/challenge (same as StaffUserProvisioner) + user.mfa_preference ||= "opt_out" + end +end diff --git a/reporting-app/spec/services/member_oidc_provisioner_spec.rb b/reporting-app/spec/services/member_oidc_provisioner_spec.rb new file mode 100644 index 00000000..303079fc --- /dev/null +++ b/reporting-app/spec/services/member_oidc_provisioner_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MemberOidcProvisioner, type: :service do + subject(:provisioner) { described_class.new } + + describe "#provision!" do + context "with valid claims" do + let(:claims) { mock_member_claims } + + it "creates a new user on first login" do + expect { provisioner.provision!(claims) }.to change(User, :count).by(1) + end + + it "returns the created user" do + user = provisioner.provision!(claims) + + expect(user).to be_persisted + expect(user.uid).to eq("member-user-456") + end + + it "sets user attributes from claims" do + user = provisioner.provision!(claims) + + expect(user.email).to eq("john.smith@example.com") + expect(user.full_name).to eq("John Smith") + end + + it "sets provider to member_oidc" do + user = provisioner.provision!(claims) + + expect(user.provider).to eq("member_oidc") + end + + it "does not set a role (members have no staff role)" do + user = provisioner.provision!(claims) + + expect(user.role).to be_nil + end + + it "handles nil name gracefully" do + claims = mock_member_claims(name: nil) + + user = provisioner.provision!(claims) + + expect(user.full_name).to be_nil + end + + it "sets mfa_preference to opt_out for new users" do + user = provisioner.provision!(claims) + + expect(user.mfa_preference).to eq("opt_out") + end + end + + context "when user already exists" do + let!(:existing_user) do + User.create!( + uid: "member-user-456", + email: "old.email@example.com", + full_name: "Old Name", + provider: "member_oidc" + ) + end + + it "finds existing user by UID" do + claims = mock_member_claims + + expect { provisioner.provision!(claims) }.not_to change(User, :count) + end + + it "updates email when changed in IdP" do + claims = mock_member_claims(email: "new.email@example.com") + + expect { provisioner.provision!(claims) } + .to change { existing_user.reload.email } + .from("old.email@example.com") + .to("new.email@example.com") + end + + it "updates name when changed in IdP" do + claims = mock_member_claims(name: "New Name") + + expect { provisioner.provision!(claims) } + .to change { existing_user.reload.full_name } + .from("Old Name") + .to("New Name") + end + + it "does not change role (members remain without role)" do + claims = mock_member_claims + + provisioner.provision!(claims) + + expect(existing_user.reload.role).to be_nil + end + + it "preserves mfa_preference if already set" do + existing_user.update!(mfa_preference: "opt_out") + claims = mock_member_claims + + provisioner.provision!(claims) + + expect(existing_user.reload.mfa_preference).to eq("opt_out") + end + + it "sets mfa_preference to opt_out if not already set" do + existing_user.update!(mfa_preference: nil) + claims = mock_member_claims + + provisioner.provision!(claims) + + expect(existing_user.reload.mfa_preference).to eq("opt_out") + end + end + + context "with invalid claims" do + it "raises ArgumentError when claims is nil" do + expect { provisioner.provision!(nil) } + .to raise_error(ArgumentError, /claims cannot be nil/) + end + + it "raises ArgumentError when uid is missing" do + claims = mock_member_claims.except(:uid) + + expect { provisioner.provision!(claims) } + .to raise_error(ArgumentError, /uid is required/) + end + + it "raises ArgumentError when uid is blank" do + claims = mock_member_claims(uid: "") + + expect { provisioner.provision!(claims) } + .to raise_error(ArgumentError, /uid is required/) + end + + it "raises ArgumentError when email is missing" do + claims = mock_member_claims.except(:email) + + expect { provisioner.provision!(claims) } + .to raise_error(ArgumentError, /email is required/) + end + + it "raises ArgumentError when email is blank" do + claims = mock_member_claims(email: "") + + expect { provisioner.provision!(claims) } + .to raise_error(ArgumentError, /email is required/) + end + end + + context "when user has existing role from staff SSO" do + let!(:existing_user) do + User.create!( + uid: "member-user-456", + email: "staff-turned-member@example.com", + full_name: "Staff Member", + provider: "sso", + role: "caseworker" + ) + end + + it "finds existing user by UID regardless of provider" do + claims = mock_member_claims + + expect { provisioner.provision!(claims) }.not_to change(User, :count) + end + + it "updates provider to member_oidc" do + claims = mock_member_claims + + expect { provisioner.provision!(claims) } + .to change { existing_user.reload.provider } + .from("sso") + .to("member_oidc") + end + + it "preserves existing role (does not clear it)" do + claims = mock_member_claims + + provisioner.provision!(claims) + + expect(existing_user.reload.role).to eq("caseworker") + end + end + + context "without role mapping (unlike staff provisioner)" do + it "successfully provisions user without any role logic" do + claims = mock_member_claims + + expect { provisioner.provision!(claims) }.not_to raise_error + end + + it "returns a user without role" do + claims = mock_member_claims + + user = provisioner.provision!(claims) + + expect(user.role).to be_nil + expect(user).to be_persisted + end + end + end +end diff --git a/reporting-app/spec/support/sso_helpers.rb b/reporting-app/spec/support/sso_helpers.rb index b32222f9..2aabbc01 100644 --- a/reporting-app/spec/support/sso_helpers.rb +++ b/reporting-app/spec/support/sso_helpers.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -# Shared test helpers for SSO testing -# Used by StaffUserProvisioner, RoleMapper, and controller specs +# Shared test helpers for SSO and OIDC testing +# Used by StaffUserProvisioner, MemberOidcProvisioner, RoleMapper, and controller specs module SsoHelpers # Returns mock staff user claims for provisioning tests # @param overrides [Hash] values to override in the default claims @@ -16,6 +16,17 @@ def mock_staff_claims(overrides = {}) }.merge(overrides) end + # Returns mock member user claims for provisioning tests + # @param overrides [Hash] values to override in the default claims + # @return [Hash] Member claims with symbol keys (no groups or region) + def mock_member_claims(overrides = {}) + { + uid: "member-user-456", + email: "john.smith@example.com", + name: "John Smith" + }.merge(overrides) + end + # Returns a mock role mapping configuration hash # @param overrides [Hash] values to override in the default config # @return [Hash] Role mapping configuration @@ -106,9 +117,10 @@ def configure_sso_for_test(config = nil) config.include SsoHelpers, type: :helper config.include SsoHelpers, sso: true - # Reset OmniAuth after each SSO test + # Reset OmniAuth after each SSO/OIDC test config.after(:each, type: :request) do OmniAuth.config.test_mode = false OmniAuth.config.mock_auth[:sso] = nil + OmniAuth.config.mock_auth[:member_oidc] = nil end end