Skip to content

Commit 1859c1b

Browse files
authored
Create MemberOidcProvisioner Service (#342)
## Overview Implements **Story 2: MemberOidcProvisioner Service** from the [Member SSO Implementation Stories](../architecture/staff-sso/member-sso-stories.md). This PR adds a provisioner service that creates or updates member users from citizen IdP OIDC claims, following the same pattern as `StaffUserProvisioner` but without role mapping logic. ## Changes ### New Files | File | Description | |------|-------------| | `app/services/member_oidc_provisioner.rb` | Provisioner service for member OIDC users | | `spec/services/member_oidc_provisioner_spec.rb` | Comprehensive test coverage | ### Modified Files | File | Description | |------|-------------| | `spec/support/sso_helpers.rb` | Added member OIDC test helpers | ## Implementation Details ### `MemberOidcProvisioner` ```ruby provisioner = MemberOidcProvisioner.new user = provisioner.provision!(claims) # claims: { uid:, email:, name: } ``` **Behavior:** - Finds existing users by `uid` (not email) to handle email changes correctly - Creates new user with `provider: "member_oidc"` if not found - Updates `email` and `full_name` on every login - Sets `mfa_preference ||= "opt_out"` so IdP handles MFA (no app MFA challenge) - Does NOT set or change `role` (members have no staff role) - Validates required claims (`uid`, `email`) and raises `ArgumentError` if missing - Does NOT raise for "no role" (unlike `StaffUserProvisioner` with deny mode) **Key difference from `StaffUserProvisioner`:** | Aspect | StaffUserProvisioner | MemberOidcProvisioner | |--------|---------------------|----------------------| | Provider | `"sso"` | `"member_oidc"` | | Role mapping | Uses `RoleMapper` | None (no role logic) | | Access denied | Raises if no matching role | Never raises for role | | Groups claim | Required for role | Not used | | Region claim | Synced if present | Not used | ### Test Helpers Added to `sso_helpers.rb` | Helper | Purpose | |--------|---------| | `mock_member_claims(overrides = {})` | Mock claims for provisioner tests | | `mock_member_omniauth_hash(overrides = {})` | Mock OmniAuth auth hash for controller tests | | `setup_member_omniauth_mock(auth_hash = nil)` | Configure OmniAuth test mode for member OIDC | | `setup_member_omniauth_failure(message)` | Configure OmniAuth failure for member OIDC | | `reset_member_omniauth` | Reset member OIDC mock auth | | `reset_all_omniauth` | Reset both SSO and member OIDC mocks | | `mock_member_oidc_config(overrides = {})` | Mock `Rails.application.config.member_oidc` | | `configure_member_oidc_for_test(config = nil)` | Stub member OIDC config in tests | ## Acceptance Criteria | Criteria | Status | |----------|--------| | `provision!(claims)` accepts `{ uid:, email:, name: }` | ✅ | | Finds user by `uid`; if not found, creates with `provider: "member_oidc"` | ✅ | | On every call, updates `email` and `full_name` from claims | ✅ | | Does not set or change `role` | ✅ | | Sets `user.mfa_preference ||= "opt_out"` | ✅ | | Returns the user; does not raise for "no role" | ✅ | | Validates required claims (uid, email) and raises `ArgumentError` if missing | ✅ | ## Test Coverage | Scenario | Tests | |----------|-------| | First login creates user with correct provider and attributes | ✅ | | Subsequent login finds by UID and updates email/name | ✅ | | Email change in IdP updates user (match by UID) | ✅ | | New and existing member OIDC users get `mfa_preference` set to `opt_out` | ✅ | | Raises when uid or email missing | ✅ | | Preserves existing role if user was previously staff | ✅ | <!-- reporting-app - begin PR environment info --> ## Preview environment for reporting-app - Service endpoint: https://p-342-reporting-app-dev-1854866940.us-east-1.elb.amazonaws.com - Deployed commit: 8c90693 <!-- reporting-app - end PR environment info -->
1 parent 50a939e commit 1859c1b

File tree

3 files changed

+287
-3
lines changed

3 files changed

+287
-3
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
# Provisions member user accounts from citizen IdP OIDC claims
4+
#
5+
# Creates or updates member users based on claims extracted from OIDC ID tokens.
6+
# Unlike StaffUserProvisioner, this does not use RoleMapper since members do not
7+
# have staff roles.
8+
#
9+
# Usage:
10+
# provisioner = MemberOidcProvisioner.new
11+
# user = provisioner.provision!(claims)
12+
#
13+
# Claims format:
14+
# {
15+
# uid: "unique-id-from-idp",
16+
# email: "member@example.com",
17+
# name: "John Smith"
18+
# }
19+
#
20+
# Behavior:
21+
# - Finds existing users by UID (not email) to handle email changes
22+
# - Updates attributes (name, email) on every login
23+
# - Does NOT set or change role (members have no staff role)
24+
# - Sets mfa_preference to "opt_out" since IdP handles MFA
25+
#
26+
class MemberOidcProvisioner
27+
PROVIDER = "member_oidc"
28+
29+
# Provision a member user from IdP claims
30+
#
31+
# @param claims [Hash] Claims extracted from OIDC ID token
32+
# @option claims [String] :uid Unique identifier from IdP (required)
33+
# @option claims [String] :email User's email address (required)
34+
# @option claims [String] :name User's full name
35+
# @return [User] The provisioned user record
36+
# @raise [ArgumentError] If required claims (uid, email) are missing
37+
# @raise [ActiveRecord::RecordInvalid] If user validation fails
38+
def provision!(claims)
39+
validate_claims!(claims)
40+
41+
user = find_or_initialize_user(claims[:uid], claims[:email])
42+
sync_attributes(user, claims)
43+
user.save!
44+
user
45+
end
46+
47+
private
48+
49+
def validate_claims!(claims)
50+
raise ArgumentError, "claims cannot be nil" if claims.nil?
51+
raise ArgumentError, "uid is required" if claims[:uid].blank?
52+
raise ArgumentError, "email is required" if claims[:email].blank?
53+
end
54+
55+
def find_or_initialize_user(uid, email)
56+
User.find_by(uid: uid) || User.new(uid: uid, email: email, provider: PROVIDER)
57+
end
58+
59+
def sync_attributes(user, claims)
60+
user.email = claims[:email]
61+
user.full_name = claims[:name]
62+
user.provider = PROVIDER
63+
64+
# IdP handles MFA; skip app MFA preference/challenge (same as StaffUserProvisioner)
65+
user.mfa_preference ||= "opt_out"
66+
end
67+
end
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe MemberOidcProvisioner, type: :service do
6+
subject(:provisioner) { described_class.new }
7+
8+
describe "#provision!" do
9+
context "with valid claims" do
10+
let(:claims) { mock_member_claims }
11+
12+
it "creates a new user on first login" do
13+
expect { provisioner.provision!(claims) }.to change(User, :count).by(1)
14+
end
15+
16+
it "returns the created user" do
17+
user = provisioner.provision!(claims)
18+
19+
expect(user).to be_persisted
20+
expect(user.uid).to eq("member-user-456")
21+
end
22+
23+
it "sets user attributes from claims" do
24+
user = provisioner.provision!(claims)
25+
26+
expect(user.email).to eq("john.smith@example.com")
27+
expect(user.full_name).to eq("John Smith")
28+
end
29+
30+
it "sets provider to member_oidc" do
31+
user = provisioner.provision!(claims)
32+
33+
expect(user.provider).to eq("member_oidc")
34+
end
35+
36+
it "does not set a role (members have no staff role)" do
37+
user = provisioner.provision!(claims)
38+
39+
expect(user.role).to be_nil
40+
end
41+
42+
it "handles nil name gracefully" do
43+
claims = mock_member_claims(name: nil)
44+
45+
user = provisioner.provision!(claims)
46+
47+
expect(user.full_name).to be_nil
48+
end
49+
50+
it "sets mfa_preference to opt_out for new users" do
51+
user = provisioner.provision!(claims)
52+
53+
expect(user.mfa_preference).to eq("opt_out")
54+
end
55+
end
56+
57+
context "when user already exists" do
58+
let!(:existing_user) do
59+
User.create!(
60+
uid: "member-user-456",
61+
email: "old.email@example.com",
62+
full_name: "Old Name",
63+
provider: "member_oidc"
64+
)
65+
end
66+
67+
it "finds existing user by UID" do
68+
claims = mock_member_claims
69+
70+
expect { provisioner.provision!(claims) }.not_to change(User, :count)
71+
end
72+
73+
it "updates email when changed in IdP" do
74+
claims = mock_member_claims(email: "new.email@example.com")
75+
76+
expect { provisioner.provision!(claims) }
77+
.to change { existing_user.reload.email }
78+
.from("old.email@example.com")
79+
.to("new.email@example.com")
80+
end
81+
82+
it "updates name when changed in IdP" do
83+
claims = mock_member_claims(name: "New Name")
84+
85+
expect { provisioner.provision!(claims) }
86+
.to change { existing_user.reload.full_name }
87+
.from("Old Name")
88+
.to("New Name")
89+
end
90+
91+
it "does not change role (members remain without role)" do
92+
claims = mock_member_claims
93+
94+
provisioner.provision!(claims)
95+
96+
expect(existing_user.reload.role).to be_nil
97+
end
98+
99+
it "preserves mfa_preference if already set" do
100+
existing_user.update!(mfa_preference: "opt_out")
101+
claims = mock_member_claims
102+
103+
provisioner.provision!(claims)
104+
105+
expect(existing_user.reload.mfa_preference).to eq("opt_out")
106+
end
107+
108+
it "sets mfa_preference to opt_out if not already set" do
109+
existing_user.update!(mfa_preference: nil)
110+
claims = mock_member_claims
111+
112+
provisioner.provision!(claims)
113+
114+
expect(existing_user.reload.mfa_preference).to eq("opt_out")
115+
end
116+
end
117+
118+
context "with invalid claims" do
119+
it "raises ArgumentError when claims is nil" do
120+
expect { provisioner.provision!(nil) }
121+
.to raise_error(ArgumentError, /claims cannot be nil/)
122+
end
123+
124+
it "raises ArgumentError when uid is missing" do
125+
claims = mock_member_claims.except(:uid)
126+
127+
expect { provisioner.provision!(claims) }
128+
.to raise_error(ArgumentError, /uid is required/)
129+
end
130+
131+
it "raises ArgumentError when uid is blank" do
132+
claims = mock_member_claims(uid: "")
133+
134+
expect { provisioner.provision!(claims) }
135+
.to raise_error(ArgumentError, /uid is required/)
136+
end
137+
138+
it "raises ArgumentError when email is missing" do
139+
claims = mock_member_claims.except(:email)
140+
141+
expect { provisioner.provision!(claims) }
142+
.to raise_error(ArgumentError, /email is required/)
143+
end
144+
145+
it "raises ArgumentError when email is blank" do
146+
claims = mock_member_claims(email: "")
147+
148+
expect { provisioner.provision!(claims) }
149+
.to raise_error(ArgumentError, /email is required/)
150+
end
151+
end
152+
153+
context "when user has existing role from staff SSO" do
154+
let!(:existing_user) do
155+
User.create!(
156+
uid: "member-user-456",
157+
email: "staff-turned-member@example.com",
158+
full_name: "Staff Member",
159+
provider: "sso",
160+
role: "caseworker"
161+
)
162+
end
163+
164+
it "finds existing user by UID regardless of provider" do
165+
claims = mock_member_claims
166+
167+
expect { provisioner.provision!(claims) }.not_to change(User, :count)
168+
end
169+
170+
it "updates provider to member_oidc" do
171+
claims = mock_member_claims
172+
173+
expect { provisioner.provision!(claims) }
174+
.to change { existing_user.reload.provider }
175+
.from("sso")
176+
.to("member_oidc")
177+
end
178+
179+
it "preserves existing role (does not clear it)" do
180+
claims = mock_member_claims
181+
182+
provisioner.provision!(claims)
183+
184+
expect(existing_user.reload.role).to eq("caseworker")
185+
end
186+
end
187+
188+
context "without role mapping (unlike staff provisioner)" do
189+
it "successfully provisions user without any role logic" do
190+
claims = mock_member_claims
191+
192+
expect { provisioner.provision!(claims) }.not_to raise_error
193+
end
194+
195+
it "returns a user without role" do
196+
claims = mock_member_claims
197+
198+
user = provisioner.provision!(claims)
199+
200+
expect(user.role).to be_nil
201+
expect(user).to be_persisted
202+
end
203+
end
204+
end
205+
end

reporting-app/spec/support/sso_helpers.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

3-
# Shared test helpers for SSO testing
4-
# Used by StaffUserProvisioner, RoleMapper, and controller specs
3+
# Shared test helpers for SSO and OIDC testing
4+
# Used by StaffUserProvisioner, MemberOidcProvisioner, RoleMapper, and controller specs
55
module SsoHelpers
66
# Returns mock staff user claims for provisioning tests
77
# @param overrides [Hash] values to override in the default claims
@@ -16,6 +16,17 @@ def mock_staff_claims(overrides = {})
1616
}.merge(overrides)
1717
end
1818

19+
# Returns mock member user claims for provisioning tests
20+
# @param overrides [Hash] values to override in the default claims
21+
# @return [Hash] Member claims with symbol keys (no groups or region)
22+
def mock_member_claims(overrides = {})
23+
{
24+
uid: "member-user-456",
25+
email: "john.smith@example.com",
26+
name: "John Smith"
27+
}.merge(overrides)
28+
end
29+
1930
# Returns a mock role mapping configuration hash
2031
# @param overrides [Hash] values to override in the default config
2132
# @return [Hash] Role mapping configuration
@@ -106,9 +117,10 @@ def configure_sso_for_test(config = nil)
106117
config.include SsoHelpers, type: :helper
107118
config.include SsoHelpers, sso: true
108119

109-
# Reset OmniAuth after each SSO test
120+
# Reset OmniAuth after each SSO/OIDC test
110121
config.after(:each, type: :request) do
111122
OmniAuth.config.test_mode = false
112123
OmniAuth.config.mock_auth[:sso] = nil
124+
OmniAuth.config.mock_auth[:member_oidc] = nil
113125
end
114126
end

0 commit comments

Comments
 (0)