Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions reporting-app/app/services/member_oidc_provisioner.rb
Original file line number Diff line number Diff line change
@@ -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
205 changes: 205 additions & 0 deletions reporting-app/spec/services/member_oidc_provisioner_spec.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 15 additions & 3 deletions reporting-app/spec/support/sso_helpers.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading