diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index 4054e20a230..bda39da195a 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -49,7 +49,9 @@ def stored_result end def selfie_requirement_met? - !decorated_sp_session.biometric_comparison_required? || stored_result.selfie_check_performed? + !FeatureManagement.idv_allow_selfie_check? || + !resolved_authn_context_result.biometric_comparison? || + stored_result.selfie_check_performed? end private diff --git a/app/controllers/concerns/idv_session_concern.rb b/app/controllers/concerns/idv_session_concern.rb index d1ceafc3858..87a715c14d3 100644 --- a/app/controllers/concerns/idv_session_concern.rb +++ b/app/controllers/concerns/idv_session_concern.rb @@ -67,7 +67,8 @@ def idv_session_user end def user_needs_biometric_comparison? - decorated_sp_session.biometric_comparison_required? && + FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? && !current_user.identity_verified_with_biometric_comparison? end end diff --git a/app/controllers/concerns/idv_step_concern.rb b/app/controllers/concerns/idv_step_concern.rb index 396dc73fa25..c2c6c166295 100644 --- a/app/controllers/concerns/idv_step_concern.rb +++ b/app/controllers/concerns/idv_step_concern.rb @@ -111,7 +111,9 @@ def flow_policy def confirm_step_allowed # set it everytime, since user may switch SP - idv_session.selfie_check_required = decorated_sp_session.biometric_comparison_required? + idv_session.selfie_check_required = + FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? return if flow_policy.controller_allowed?(controller: self.class) redirect_to url_for_latest_step diff --git a/app/controllers/idv/document_capture_controller.rb b/app/controllers/idv/document_capture_controller.rb index 1801378902e..e2330286120 100644 --- a/app/controllers/idv/document_capture_controller.rb +++ b/app/controllers/idv/document_capture_controller.rb @@ -43,6 +43,10 @@ def update end def extra_view_variables + doc_auth_selfie_capture = + FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? + { document_capture_session_uuid: document_capture_session_uuid, flow_path: 'standard', @@ -51,7 +55,7 @@ def extra_view_variables skip_doc_auth: idv_session.skip_doc_auth, skip_doc_auth_from_handoff: idv_session.skip_doc_auth_from_handoff, opted_in_to_in_person_proofing: idv_session.opted_in_to_in_person_proofing, - doc_auth_selfie_capture: decorated_sp_session.biometric_comparison_required?, + doc_auth_selfie_capture:, }.merge( acuant_sdk_upgrade_a_b_testing_variables, ) @@ -90,6 +94,10 @@ def cancel_establishing_in_person_enrollments end def analytics_arguments + liveness_checking_required = + FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? + { flow_path: flow_path, step: 'document_capture', @@ -97,8 +105,8 @@ def analytics_arguments irs_reproofing: irs_reproofing?, redo_document_capture: idv_session.redo_document_capture, skip_hybrid_handoff: idv_session.skip_hybrid_handoff, - liveness_checking_required: decorated_sp_session.biometric_comparison_required?, - selfie_check_required: idv_session.selfie_check_required, + liveness_checking_required:, + selfie_check_required: liveness_checking_required, }.merge(ab_test_analytics_buckets) end diff --git a/app/controllers/idv/hybrid_mobile/capture_complete_controller.rb b/app/controllers/idv/hybrid_mobile/capture_complete_controller.rb index d40671dddb6..4ed8ece9efd 100644 --- a/app/controllers/idv/hybrid_mobile/capture_complete_controller.rb +++ b/app/controllers/idv/hybrid_mobile/capture_complete_controller.rb @@ -20,12 +20,16 @@ def show private def analytics_arguments + liveness_checking_required = + FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? + { flow_path: 'hybrid', step: 'capture_complete', analytics_id: 'Doc Auth', irs_reproofing: irs_reproofing?, - liveness_checking_required: decorated_sp_session.biometric_comparison_required?, + liveness_checking_required:, }.merge(ab_test_analytics_buckets) end end diff --git a/app/controllers/idv/hybrid_mobile/document_capture_controller.rb b/app/controllers/idv/hybrid_mobile/document_capture_controller.rb index ae138d92096..0efbef1595d 100644 --- a/app/controllers/idv/hybrid_mobile/document_capture_controller.rb +++ b/app/controllers/idv/hybrid_mobile/document_capture_controller.rb @@ -39,11 +39,14 @@ def update end def extra_view_variables + doc_auth_selfie_capture = FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? + { flow_path: 'hybrid', document_capture_session_uuid: document_capture_session_uuid, failure_to_proof_url: return_to_sp_failure_to_proof_url(step: 'document_capture'), - doc_auth_selfie_capture: decorated_sp_session.biometric_comparison_required?, + doc_auth_selfie_capture:, }.merge( acuant_sdk_upgrade_a_b_testing_variables, ) @@ -52,13 +55,17 @@ def extra_view_variables private def analytics_arguments + biometric_comparison_required = + FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? + { flow_path: 'hybrid', step: 'document_capture', analytics_id: 'Doc Auth', irs_reproofing: irs_reproofing?, - liveness_checking_required: decorated_sp_session.biometric_comparison_required?, - selfie_check_required: decorated_sp_session.biometric_comparison_required?, + liveness_checking_required: biometric_comparison_required, + selfie_check_required: biometric_comparison_required, }.merge( ab_test_analytics_buckets, ) diff --git a/app/controllers/idv/image_uploads_controller.rb b/app/controllers/idv/image_uploads_controller.rb index e9a7105e6ad..535c0ea436c 100644 --- a/app/controllers/idv/image_uploads_controller.rb +++ b/app/controllers/idv/image_uploads_controller.rb @@ -25,7 +25,7 @@ def image_upload_form uuid_prefix: current_sp&.app_id, irs_attempts_api_tracker: irs_attempts_api_tracker, store_encrypted_images: store_encrypted_images?, - liveness_checking_required: decorated_sp_session.biometric_comparison_required?, + liveness_checking_required: resolved_authn_context_result.biometric_comparison?, ) end diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb index 1f0c366e581..272fe1d2123 100644 --- a/app/controllers/idv_controller.rb +++ b/app/controllers/idv_controller.rb @@ -32,11 +32,12 @@ def activated private def already_verified? - if decorated_sp_session.biometric_comparison_required? - return current_user.identity_verified_with_biometric_comparison? + if FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? + current_user.identity_verified_with_biometric_comparison? + else + current_user.active_profile.present? end - - return current_user.active_profile.present? end def verify_identity diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index 43d60764941..2e46ad869e9 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -127,7 +127,8 @@ def identity_needs_verification? end def biometric_comparison_needed? - decorated_sp_session.biometric_comparison_required? && + FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? && !current_user.identity_verified_with_biometric_comparison? end diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index d7f37c7c2fa..b4dd3343e81 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -113,7 +113,8 @@ def prompt_for_password_if_ial2_request_and_pii_locked end def biometric_comparison_needed? - decorated_sp_session.biometric_comparison_required? && + FeatureManagement.idv_allow_selfie_check? && + resolved_authn_context_result.biometric_comparison? && !current_user.identity_verified_with_biometric_comparison? end diff --git a/app/decorators/null_service_provider_session.rb b/app/decorators/null_service_provider_session.rb index 716d3876754..6bcdfee282a 100644 --- a/app/decorators/null_service_provider_session.rb +++ b/app/decorators/null_service_provider_session.rb @@ -43,10 +43,6 @@ def request_url_params {} end - def biometric_comparison_required? - false - end - def current_user view_context&.current_user end diff --git a/app/decorators/service_provider_session.rb b/app/decorators/service_provider_session.rb index 83c2d92490a..5f31207096c 100644 --- a/app/decorators/service_provider_session.rb +++ b/app/decorators/service_provider_session.rb @@ -72,11 +72,6 @@ def sp_issuer sp.issuer end - def biometric_comparison_required? - !!(FeatureManagement.idv_allow_selfie_check? && - sp_session[:biometric_comparison_required]) - end - def cancel_link_url view_context.new_user_session_url(request_id: sp_session[:request_id]) end diff --git a/spec/controllers/concerns/idv/document_capture_concern_spec.rb b/spec/controllers/concerns/idv/document_capture_concern_spec.rb index 5b2375acfc8..3bd448c797e 100644 --- a/spec/controllers/concerns/idv/document_capture_concern_spec.rb +++ b/spec/controllers/concerns/idv/document_capture_concern_spec.rb @@ -19,20 +19,22 @@ def show context 'selfie checks enabled' do before do - decorated_sp_session = instance_double(ServiceProviderSession) - allow(decorated_sp_session).to receive(:biometric_comparison_required?). - and_return(biometric_comparison_required) - allow(controller).to receive(:decorated_sp_session).and_return(decorated_sp_session) + allow(FeatureManagement).to receive(:idv_allow_selfie_check?).and_return(true) + stored_result = instance_double(DocumentCaptureSessionResult) allow(stored_result).to receive(:selfie_check_performed?).and_return(selfie_check_performed) allow(controller).to receive(:stored_result).and_return(stored_result) + + resolution_result = Vot::Parser.new(vector_of_trust: vot).parse + allow(controller).to receive(:resolved_authn_context_result).and_return(resolution_result) end context 'SP requires biometric_comparison' do - let(:biometric_comparison_required) { true } + let(:vot) { 'Pb' } context 'selfie check performed' do let(:selfie_check_performed) { true } + it 'returns true' do expect(controller.selfie_requirement_met?).to eq(true) end @@ -40,6 +42,7 @@ def show context 'selfie check not performed' do let(:selfie_check_performed) { false } + it 'returns false' do expect(controller.selfie_requirement_met?).to eq(false) end @@ -47,10 +50,11 @@ def show end context 'SP does not require biometric_comparison' do - let(:biometric_comparison_required) { false } + let(:vot) { 'P1' } context 'selfie check performed' do let(:selfie_check_performed) { true } + it 'returns true' do expect(controller.selfie_requirement_met?).to eq(true) end diff --git a/spec/controllers/idv/by_mail/request_letter_controller_spec.rb b/spec/controllers/idv/by_mail/request_letter_controller_spec.rb index 1ee2a617877..273194c8417 100644 --- a/spec/controllers/idv/by_mail/request_letter_controller_spec.rb +++ b/spec/controllers/idv/by_mail/request_letter_controller_spec.rb @@ -274,7 +274,7 @@ def expect_resend_letter_to_send_letter_and_redirect(otp:) allow(Pii::Cacher).to receive(:new).and_return(pii_cacher) service_provider = create(:service_provider, issuer: '123abc') - session[:sp] = { issuer: service_provider.issuer } + session[:sp] = { issuer: service_provider.issuer, vtr: ['C1'] } gpo_confirmation_maker = instance_double(GpoConfirmationMaker) allow(GpoConfirmationMaker).to receive(:new). diff --git a/spec/controllers/idv/document_capture_controller_spec.rb b/spec/controllers/idv/document_capture_controller_spec.rb index 984b89f40dc..6d04ae0305c 100644 --- a/spec/controllers/idv/document_capture_controller_spec.rb +++ b/spec/controllers/idv/document_capture_controller_spec.rb @@ -30,8 +30,15 @@ stub_up_to(:hybrid_handoff, idv_session: subject.idv_session) stub_analytics subject.idv_session.document_capture_session_uuid = document_capture_session_uuid - allow(controller.decorated_sp_session).to receive(:biometric_comparison_required?). - and_return(doc_auth_selfie_capture_enabled && sp_selfie_enabled) + + vot = (doc_auth_selfie_capture_enabled && sp_selfie_enabled) ? 'Pb' : 'P1' + resolved_authn_context = Vot::Parser.new(vector_of_trust: vot).parse + + allow(FeatureManagement).to receive(:idv_allow_selfie_check?). + and_return(doc_auth_selfie_capture_enabled) + + allow(controller).to receive(:resolved_authn_context_result). + and_return(resolved_authn_context) subject.idv_session.flow_path = flow_path allow(subject).to receive(:ab_test_analytics_buckets).and_return(ab_test_args) end @@ -40,10 +47,13 @@ it 'returns a valid StepInfo object' do expect(Idv::DocumentCaptureController.step_info).to be_valid end + context 'when selfie feature is enabled system wide' do let(:doc_auth_selfie_capture_enabled) { true } + describe 'with sp selfie disabled' do let(:sp_selfie_enabled) { false } + it 'does not satisfy precondition' do expect(Idv::DocumentCaptureController.step_info.preconditions.is_a?(Proc)) expect(subject).to receive(:render). @@ -52,16 +62,21 @@ expect(response).to render_template :show end end + describe 'with sp selfie enabled' do let(:sp_selfie_enabled) { true } + before do allow(IdentityConfig.store).to receive(:doc_auth_selfie_desktop_test_mode). and_return(false) end + it 'does satisfy precondition' do expect(Idv::DocumentCaptureController.step_info.preconditions.is_a?(Proc)) expect(subject).not_to receive(:render).with(:show, locals: an_instance_of(Hash)) + get :show + expect(response).to redirect_to(idv_hybrid_handoff_path) end end @@ -255,15 +270,19 @@ end context 'ipp disabled for sp' do + let(:sp_selfie_enabled) { true } + let(:doc_auth_selfie_capture_enabled) { true } + before do allow(IdentityConfig.store).to receive(:doc_auth_selfie_desktop_test_mode).and_return(false) allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).with(anything).and_return(false) - allow(subject.decorated_sp_session).to receive(:biometric_comparison_required?). - and_return(true) end + it 'redirect back when accessed from handoff' do subject.idv_session.skip_hybrid_handoff = nil + get :show, params: { step: 'hybrid_handoff' } + expect(response).to redirect_to(idv_hybrid_handoff_url) expect(subject.idv_session.skip_doc_auth_from_handoff).to_not eq(true) end diff --git a/spec/controllers/idv/hybrid_handoff_controller_spec.rb b/spec/controllers/idv/hybrid_handoff_controller_spec.rb index 35c86c9fa4c..e16b188831b 100644 --- a/spec/controllers/idv/hybrid_handoff_controller_spec.rb +++ b/spec/controllers/idv/hybrid_handoff_controller_spec.rb @@ -25,8 +25,14 @@ stub_attempts_tracker allow(subject).to receive(:ab_test_analytics_buckets).and_return(ab_test_args) allow(subject.idv_session).to receive(:service_provider).and_return(service_provider) - allow(subject.decorated_sp_session).to receive(:biometric_comparison_required?). - and_return(sp_selfie_enabled && doc_auth_selfie_capture_enabled) + + resolved_authn_context_result = sp_selfie_enabled && doc_auth_selfie_capture_enabled ? + Vot::Parser.new(vector_of_trust: 'Pb').parse : + Vot::Parser.new(vector_of_trust: 'P1').parse + + allow(subject).to receive(:resolved_authn_context_result). + and_return(resolved_authn_context_result) + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { in_person_proofing } allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { ipp_opt_in_enabled @@ -261,8 +267,10 @@ context 'with selfie enabled system wide' do let(:doc_auth_selfie_capture_enabled) { true } + describe 'when selfie is enabled for sp' do let(:sp_selfie_enabled) { true } + it 'pass on correct flags and states and logs correct info' do get :show expect(response).to render_template :show @@ -270,8 +278,10 @@ expect(subject.idv_session.selfie_check_required).to eq(true) end end + describe 'when selfie is disabled for sp' do let(:sp_selfie_enabled) { false } + it 'pass on correct flags and states and logs correct info' do get :show expect(response).to render_template :show diff --git a/spec/controllers/idv/hybrid_mobile/document_capture_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/document_capture_controller_spec.rb index 4a260d4ff14..035cf5527c7 100644 --- a/spec/controllers/idv/hybrid_mobile/document_capture_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/document_capture_controller_spec.rb @@ -58,8 +58,8 @@ flow_path: 'hybrid', irs_reproofing: false, step: 'document_capture', - liveness_checking_required: false, - selfie_check_required: boolean, + selfie_check_required: false, + liveness_checking_required: boolean, }.merge(ab_test_args) end @@ -76,30 +76,26 @@ expect(response).to render_template :show end - context 'when a selfie is requested' do + context 'when selfie is required' do before do - allow(subject).to receive(:decorated_sp_session). - and_return( - double( - 'decorated_session', - { biometric_comparison_required?: true, sp_name: 'sp' }, - ), - ) + allow(FeatureManagement).to receive(:idv_allow_selfie_check?).and_return(true) + + authn_context_result = Vot::Parser.new(vector_of_trust: 'Pb').parse + allow(subject).to receive(:resolved_authn_context_result).and_return(authn_context_result) end - context 'when selfie is required by sp session' do - it 'requests FE to display selfie' do - expect(subject).to receive(:render).with( - :show, - locals: hash_including( - document_capture_session_uuid: document_capture_session_uuid, - doc_auth_selfie_capture: true, - ), - ).and_call_original - get :show + it 'requests FE to display selfie' do + expect(subject).to receive(:render).with( + :show, + locals: hash_including( + document_capture_session_uuid: document_capture_session_uuid, + doc_auth_selfie_capture: true, + ), + ).and_call_original - expect(response).to render_template :show - end + get :show + + expect(response).to render_template :show end end diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index ec09b8904af..fc895b93778 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -336,8 +336,10 @@ let(:selfie_img) { DocAuthImageFixtures.selfie_image_multipart } before do - allow(controller.decorated_sp_session).to receive(:biometric_comparison_required?). - and_return(true) + resolved_authn_context_result = Vot::Parser.new(vector_of_trust: 'Pb').parse + + allow(controller).to receive(:resolved_authn_context_result). + and_return(resolved_authn_context_result) end it 'returns a successful response and modifies the session' do @@ -1240,8 +1242,10 @@ context 'the frontend requests a selfie' do before do - allow(controller).to receive(:decorated_sp_session). - and_return(double('decorated_session', { biometric_comparison_required?: true })) + authn_context_result = Vot::Parser.new(vector_of_trust: 'Pb').parse + allow(controller).to( + receive(:resolved_authn_context_result).and_return(authn_context_result), + ) end let(:back_image) { DocAuthImageFixtures.portrait_match_success_yaml } diff --git a/spec/controllers/idv/personal_key_controller_spec.rb b/spec/controllers/idv/personal_key_controller_spec.rb index 1257197223f..492b12376fb 100644 --- a/spec/controllers/idv/personal_key_controller_spec.rb +++ b/spec/controllers/idv/personal_key_controller_spec.rb @@ -420,7 +420,10 @@ def assert_personal_key_generated_for_profiles(*profile_pii_pairs) describe '#update' do context 'user selected phone verification' do it 'redirects to sign up completed for an sp' do - subject.session[:sp] = { issuer: create(:service_provider).issuer } + subject.session[:sp] = { + issuer: create(:service_provider).issuer, + vtr: ['C1'], + } patch :update expect(response).to redirect_to sign_up_completed_url diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index c39ed2b71e6..ec3c449fe43 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -414,7 +414,7 @@ it 'modifies pii as expected' do app_id = 'hello-world' sp = create(:service_provider, app_id: app_id) - sp_session = { issuer: sp.issuer } + sp_session = { issuer: sp.issuer, vtr: ['C1'] } allow(controller).to receive(:sp_session).and_return(sp_session) expect(Idv::Agent).to receive(:new).with( diff --git a/spec/controllers/openid_connect/authorization_controller_spec.rb b/spec/controllers/openid_connect/authorization_controller_spec.rb index 55fba516256..9f27bd1abe1 100644 --- a/spec/controllers/openid_connect/authorization_controller_spec.rb +++ b/spec/controllers/openid_connect/authorization_controller_spec.rb @@ -13,7 +13,7 @@ let(:client_id) { 'urn:gov:gsa:openidconnect:test' } let(:service_provider) { build(:service_provider, issuer: client_id) } let(:prompt) { 'select_account' } - let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + let(:acr_values) { nil } let(:vtr) { nil } let(:params) do { @@ -26,7 +26,7 @@ scope: 'openid profile', state: SecureRandom.hex, vtr: vtr, - } + }.compact end describe '#index' do @@ -54,7 +54,10 @@ session[:sign_in_flow] = sign_in_flow end - context 'with valid params' do + context 'acr with valid params' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } + it 'redirects back to the client app with a code if server-side redirect is enabled' do allow(IdentityConfig.store).to receive(:openid_connect_redirect). and_return('server_side') @@ -105,37 +108,6 @@ context 'with ial1 requested using acr_values' do it 'tracks IAL1 authentication event' do stub_analytics - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request', - success: true, - client_id: client_id, - prompt: 'select_account', - referer: nil, - allow_prompt_login: true, - errors: {}, - unauthorized_scope: true, - user_fully_authenticated: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', - code_challenge_present: false, - service_provider_pkce: nil, - scope: 'openid', - vtr: nil, - vtr_param: '') - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request handoff', - success: true, - client_id: client_id, - user_sp_authorized: true, - code_digest: kind_of(String)) - expect(@analytics).to receive(:track_event). - with( - 'SP redirect initiated', - ial: 1, - billed_ial: 1, - sign_in_flow:, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', - vtr: nil, - ) IdentityLinker.new(user, service_provider).link_identity(ial: 1) user.identities.last.update!(verified_attributes: %w[given_name family_name birthdate]) @@ -144,6 +116,39 @@ sp_return_log = SpReturnLog.find_by(issuer: client_id) expect(sp_return_log.ial).to eq(1) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: true, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: nil, + vtr_param: nil, + ) + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 1, + billed_ial: 1, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + vtr: nil, + ) end end @@ -157,37 +162,6 @@ it 'tracks IAL1 authentication event' do stub_analytics - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request', - success: true, - client_id: client_id, - prompt: 'select_account', - referer: nil, - allow_prompt_login: true, - errors: {}, - unauthorized_scope: true, - user_fully_authenticated: true, - acr_values: '', - code_challenge_present: false, - service_provider_pkce: nil, - scope: 'openid', - vtr: ['C1'], - vtr_param: ['C1'].to_json) - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request handoff', - success: true, - client_id: client_id, - user_sp_authorized: true, - code_digest: kind_of(String)) - expect(@analytics).to receive(:track_event). - with( - 'SP redirect initiated', - ial: 1, - billed_ial: 1, - sign_in_flow:, - acr_values: '', - vtr: ['C1'], - ) IdentityLinker.new(user, service_provider).link_identity(ial: 1) user.identities.last.update!(verified_attributes: %w[given_name family_name birthdate]) @@ -196,11 +170,46 @@ sp_return_log = SpReturnLog.find_by(issuer: client_id) expect(sp_return_log.ial).to eq(1) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: true, + user_fully_authenticated: true, + acr_values: '', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: ['C1'], + vtr_param: ['C1'].to_json, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 1, + billed_ial: 1, + sign_in_flow:, + acr_values: '', + vtr: ['C1'], + ) end end - context 'with ial2 requested' do - before { params[:acr_values] = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + context 'with ial2 requested using acr values' do + let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } context 'account is already verified' do let(:user) do @@ -346,37 +355,6 @@ it 'tracks IAL2 authentication event' do stub_analytics - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request', - success: true, - client_id: client_id, - prompt: 'select_account', - referer: nil, - allow_prompt_login: true, - errors: {}, - unauthorized_scope: false, - user_fully_authenticated: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', - code_challenge_present: false, - service_provider_pkce: nil, - scope: 'openid profile', - vtr: nil, - vtr_param: '') - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request handoff', - success: true, - client_id: client_id, - user_sp_authorized: true, - code_digest: kind_of(String)) - expect(@analytics).to receive(:track_event). - with( - 'SP redirect initiated', - ial: 2, - billed_ial: 2, - sign_in_flow:, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', - vtr: nil, - ) IdentityLinker.new(user, service_provider).link_identity(ial: 2) user.identities.last.update!( @@ -387,13 +365,104 @@ sp_return_log = SpReturnLog.find_by(issuer: client_id) expect(sp_return_log.ial).to eq(2) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: false, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid profile', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 2, + billed_ial: 2, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', + vtr: nil, + ) + end + + context 'SP requests biometric_comparison_required' do + let(:selfie_capture_enabled) { true } + let(:vtr) { ['Pb'].to_json } + + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(selfie_capture_enabled) + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + end + + context 'selfie check was performed' do + it 'redirects to the redirect_uri immediately when pii is unlocked if client-side redirect is disabled' do + user.active_profile.idv_level = :unsupervised_with_selfie + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + end + + context 'selfie check was not performed' do + it 'redirects to have the user verify their account' do + action + expect(controller).to redirect_to(idv_url) + end + end + + context 'selfie capture not enabled, biometric comparison required' do + let(:selfie_capture_enabled) { false } + let(:vtr) { ['Pb'].to_json } + + it 'returns status not_acceptable' do + action + + expect(response.status).to eq(406) + end + end + + context 'selfie capture not enabled, biometric comparison not required' do + let(:selfie_capture_enabled) { false } + let(:vtr) { ['P1'].to_json } + + it 'redirects to the service provider' do + action + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + end end context 'SP has a vector of trust that includes a biometric comparison' do let(:selfie_capture_enabled) { true } + let(:acr_values) { nil } + let(:vtr) { ['Pb'].to_json } + before do - params[:acr_values] = nil - params[:vtr] = ['C1.C2.P1.Pb'].to_json expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). and_return(selfie_capture_enabled) allow(IdentityConfig.store).to receive(:openid_connect_redirect). @@ -486,11 +555,11 @@ context 'sp requests biometrics' do let(:selfie_capture_enabled) { true } let(:user) { create(:profile, :active, :verified).user } + let(:vtr) { ['C1.C2.P1.Pb'].to_json } before do expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). and_return(selfie_capture_enabled) - params[:vtr] = ['C1.C2.P1.Pb'].to_json end it 'redirects to gpo enter code page' do @@ -660,37 +729,6 @@ it 'tracks IAL2 authentication event' do stub_analytics - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request', - success: true, - client_id: client_id, - prompt: 'select_account', - referer: nil, - allow_prompt_login: true, - errors: {}, - unauthorized_scope: false, - user_fully_authenticated: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', - code_challenge_present: false, - service_provider_pkce: nil, - scope: 'openid profile', - vtr: nil, - vtr_param: '') - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request handoff', - success: true, - client_id: client_id, - user_sp_authorized: true, - code_digest: kind_of(String)) - expect(@analytics).to receive(:track_event). - with( - 'SP redirect initiated', - ial: 0, - billed_ial: 2, - sign_in_flow:, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', - vtr: nil, - ) IdentityLinker.new(user, service_provider).link_identity(ial: 2) user.identities.last.update!( @@ -701,6 +739,41 @@ sp_return_log = SpReturnLog.find_by(issuer: client_id) expect(sp_return_log.ial).to eq(2) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: false, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid profile', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 0, + billed_ial: 2, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + vtr: nil, + ) end end @@ -748,36 +821,6 @@ it 'tracks IAL1 authentication event' do stub_analytics - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request', - success: true, - client_id: client_id, - prompt: 'select_account', - referer: nil, - allow_prompt_login: true, - errors: {}, - unauthorized_scope: false, - user_fully_authenticated: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', - code_challenge_present: false, - service_provider_pkce: nil, - scope: 'openid profile', - vtr: nil, - vtr_param: '') - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request handoff', - success: true, - client_id: client_id, - user_sp_authorized: true, - code_digest: kind_of(String)) - expect(@analytics).to receive(:track_event).with( - 'SP redirect initiated', - ial: 0, - billed_ial: 1, - sign_in_flow:, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', - vtr: nil, - ) IdentityLinker.new(user, service_provider).link_identity(ial: 1) user.identities.last.update!( @@ -787,6 +830,41 @@ sp_return_log = SpReturnLog.find_by(issuer: client_id) expect(sp_return_log.ial).to eq(1) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: false, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid profile', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 0, + billed_ial: 1, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + vtr: nil, + ) end end @@ -837,36 +915,6 @@ it 'tracks IAL1 authentication event' do stub_analytics - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request', - success: true, - client_id: client_id, - prompt: 'select_account', - referer: nil, - allow_prompt_login: true, - errors: {}, - unauthorized_scope: false, - user_fully_authenticated: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', - code_challenge_present: false, - service_provider_pkce: nil, - scope: 'openid profile', - vtr: nil, - vtr_param: '') - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request handoff', - success: true, - client_id: client_id, - user_sp_authorized: true, - code_digest: kind_of(String)) - expect(@analytics).to receive(:track_event).with( - 'SP redirect initiated', - ial: 0, - billed_ial: 1, - sign_in_flow:, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', - vtr: nil, - ) IdentityLinker.new(user, service_provider).link_identity(ial: 1) user.identities.last.update!( @@ -876,6 +924,41 @@ sp_return_log = SpReturnLog.find_by(issuer: client_id) expect(sp_return_log.ial).to eq(1) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: false, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid profile', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 0, + billed_ial: 1, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + vtr: nil, + ) end end end @@ -943,26 +1026,29 @@ end end - context 'with invalid params that do not interfere with the redirect_uri' do - before { params[:prompt] = '' } + context 'vtr with valid params' do + let(:vtr) { ['C1'].to_json } - it 'redirects the user with an invalid request if client-side redirect is disabled' do + it 'redirects back to the client app with a code if server-side redirect is enabled' do allow(IdentityConfig.store).to receive(:openid_connect_redirect). and_return('server_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!(verified_attributes: %w[given_name family_name birthdate]) action expect(response).to redirect_to(/^#{params[:redirect_uri]}/) redirect_params = UriService.params(response.location) - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:code]).to be_present expect(redirect_params[:state]).to eq(params[:state]) end - it 'renders client-side redirect with an invalid request if client-side redirect is enabled' do + it 'renders a client-side redirect back to the client app with a code if it is enabled' do allow(IdentityConfig.store).to receive(:openid_connect_redirect). and_return('client_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!(verified_attributes: %w[given_name family_name birthdate]) action expect(controller).to render_template('openid_connect/shared/redirect') @@ -970,14 +1056,15 @@ redirect_params = UriService.params(assigns(:oidc_redirect_uri)) - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:code]).to be_present expect(redirect_params[:state]).to eq(params[:state]) end - it 'renders JS client-side redirect with an invalid request if JS client-side redirect is enabled' do + it 'renders a JS client-side redirect back to the client app with a code if it is enabled' do allow(IdentityConfig.store).to receive(:openid_connect_redirect). and_return('client_side_js') + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!(verified_attributes: %w[given_name family_name birthdate]) action expect(controller).to render_template('openid_connect/shared/redirect_js') @@ -985,218 +1072,1386 @@ redirect_params = UriService.params(assigns(:oidc_redirect_uri)) - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:code]).to be_present expect(redirect_params[:state]).to eq(params[:state]) end - it 'redirects the user with an invalid request if UUID is in server-side redirect list' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('client_side') - allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). - and_return({ user.uuid => 'server_side' }) - action + context 'with ial1 requested using acr_values' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } - expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + it 'tracks IAL1 authentication event' do + stub_analytics + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!(verified_attributes: %w[given_name family_name birthdate]) - redirect_params = UriService.params(response.location) + action - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present - expect(redirect_params[:state]).to eq(params[:state]) - end + sp_return_log = SpReturnLog.find_by(issuer: client_id) + expect(sp_return_log.ial).to eq(1) - it 'renders client-side redirect with an invalid request if UUID is overriden for client-side redirect' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('server_side') - allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). - and_return({ user.uuid => 'client_side' }) - action + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: true, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 1, + billed_ial: 1, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + vtr: nil, + ) + end + end - expect(controller).to render_template('openid_connect/shared/redirect') - expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + context 'with ial1 requested using vtr' do + let(:acr_values) { nil } + let(:vtr) { ['C1'].to_json } - redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + before do + allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) + end - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present - expect(redirect_params[:state]).to eq(params[:state]) - end + it 'tracks IAL1 authentication event' do + stub_analytics - it 'renders JS client-side redirect with an invalid request if UUID is overriden for JS client-side redirect' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('server_side') - allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). - and_return({ user.uuid => 'client_side_js' }) - action + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!(verified_attributes: %w[given_name family_name birthdate]) - expect(controller).to render_template('openid_connect/shared/redirect_js') - expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + action - redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + sp_return_log = SpReturnLog.find_by(issuer: client_id) + expect(sp_return_log.ial).to eq(1) - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present - expect(redirect_params[:state]).to eq(params[:state]) + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: true, + user_fully_authenticated: true, + acr_values: '', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: ['C1'], + vtr_param: ['C1'].to_json, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 1, + billed_ial: 1, + sign_in_flow:, + acr_values: '', + vtr: ['C1'], + ) + end end - it 'tracks the event with errors' do - stub_analytics - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request', - success: false, - client_id: client_id, - prompt: '', - referer: nil, - allow_prompt_login: true, - unauthorized_scope: true, - errors: hash_including(:prompt), - error_details: hash_including(:prompt), - user_fully_authenticated: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', - code_challenge_present: false, - service_provider_pkce: nil, - scope: 'openid', - vtr: nil, - vtr_param: '') - expect(@analytics).to_not receive(:track_event).with('sp redirect initiated') + context 'with ial2 requested using acr' do + let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } - action + context 'account is already verified' do + let(:user) do + create( + :profile, :active, :verified, proofing_components: { liveness_check: true } + ).user + end - expect(SpReturnLog.count).to eq(0) - end - end + it 'redirects to the redirect_uri immediately when pii is unlocked if client-side redirect is disabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action - context 'with invalid params that mean the redirect_uri is not trusted' do - before { params.delete(:client_id) } + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end - it 'renders the error page' do - action - expect(controller).to render_template('openid_connect/authorization/error') - end + it 'renders a client-side redirect back to the client app immediately if it is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action - it 'tracks the event with errors' do - stub_analytics - expect(@analytics).to receive(:track_event). - with('OpenID Connect: authorization request', - success: false, - client_id: nil, - prompt: 'select_account', - referer: nil, - allow_prompt_login: nil, - unauthorized_scope: true, - errors: hash_including(:client_id), - error_details: hash_including(:client_id), - user_fully_authenticated: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', - code_challenge_present: false, - service_provider_pkce: nil, - scope: 'openid', - vtr: nil, - vtr_param: '') - expect(@analytics).to_not receive(:track_event).with('SP redirect initiated') + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end - action + it 'renders a JS client-side redirect back to the client app immediately if it is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'redirects back to the client app immediately if UUID is overridden to server-side redirect' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'server_side' }) + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + + it 'renders a client-side redirect back to the client app immediately if UUID is overridden to client-side redirect' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'client_side' }) + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'renders a JS client-side redirect back to the client app immediately if UUID is overridden to JS client-side redirect' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'client_side_js' }) + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'respects UUID redirect config when issuer config is also set' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_issuer_override_map). + and_return({ service_provider.issuer => 'client_side' }) + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'client_side_js' }) + + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'respects issuer redirect config if UUID config is not set' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_issuer_override_map). + and_return({ service_provider.issuer => 'client_side_js' }) + + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'redirects to the password capture url when pii is locked' do + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(true) + action + + expect(response).to redirect_to(capture_password_url) + end + + it 'tracks IAL2 authentication event' do + stub_analytics + + IdentityLinker.new(user, service_provider).link_identity(ial: 2) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + sp_return_log = SpReturnLog.find_by(issuer: client_id) + expect(sp_return_log.ial).to eq(2) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: false, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid profile', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 2, + billed_ial: 2, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', + vtr: nil, + ) + end + + context 'SP requests biometric_comparison_required' do + let(:selfie_capture_enabled) { true } + let(:vtr) { ['Pb'].to_json } + + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(selfie_capture_enabled) + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + end + + context 'selfie check was performed' do + it 'redirects to the redirect_uri immediately when pii is unlocked if client-side redirect is disabled' do + user.active_profile.idv_level = :unsupervised_with_selfie + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + end + + context 'selfie check was not performed' do + it 'redirects to have the user verify their account' do + action + expect(controller).to redirect_to(idv_url) + end + end + + context 'selfie capture not enabled, biometric comparison required' do + let(:selfie_capture_enabled) { false } + let(:vtr) { ['Pb'].to_json } + + it 'returns status not_acceptable' do + action + + expect(response.status).to eq(406) + end + end + + context 'selfie capture not enabled, biometric comparison not required' do + let(:selfie_capture_enabled) { false } + let(:vtr) { ['P1'].to_json } + + it 'redirects to the service provider' do + action + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + end + end + + context 'SP has a vector of trust that includes a biometric comparison' do + let(:selfie_capture_enabled) { true } + let(:acr_values) { nil } + let(:vtr) { ['Pb'].to_json } + + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(selfie_capture_enabled) + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + end + + context 'selfie check was performed' do + it 'redirects to the redirect_uri immediately when pii is unlocked if client-side redirect is disabled' do + user.active_profile.idv_level = :unsupervised_with_selfie + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + end + + context 'selfie check was not performed' do + it 'redirects to have the user verify their account' do + action + expect(controller).to redirect_to(idv_url) + end + end + + context 'biometric comparison was performed in-person' do + it 'redirects to the redirect_uri immediately when pii is unlocked if client-side redirect is disabled' do + user.active_profile.idv_level = :in_person + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + end + + context 'selfie capture not enabled, biometric_comparison_check requested by sp' do + let(:selfie_capture_enabled) { false } + it 'returns status not_acceptable' do + action + + expect(response.status).to eq(406) + end + end + end + end + + context 'verified non-biometric profile with pending biometric profile' do + before do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[birthdate family_name given_name verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + end + + context 'sp does not request biometrics' do + let(:selfie_capture_enabled) { true } + let(:user) { create(:profile, :active, :verified).user } + + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(selfie_capture_enabled) + end + + it 'redirects to the redirect_uri immediately when pii is unlocked if client-side redirect is disabled' do + create(:profile, :verify_by_mail_pending, :with_pii, idv_level: :unsupervised_with_selfie, user: user) + user.active_profile.idv_level = :legacy_unsupervised + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + expect(user.identities.last.verified_attributes).to eq(%w[birthdate family_name given_name verified_at]) + end + + it 'redirects to please call page if user has a fraudualent profile' do + create(:profile, :fraud_review_pending, :with_pii, idv_level: :unsupervised_with_selfie, user: user) + + action + + expect(response).to redirect_to(idv_please_call_url) + end + end + + context 'sp requests biometrics' do + let(:selfie_capture_enabled) { true } + let(:user) { create(:profile, :active, :verified).user } + let(:vtr) { ['C1.C2.P1.Pb'].to_json } + + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(selfie_capture_enabled) + end + + it 'redirects to gpo enter code page' do + create(:profile, :verify_by_mail_pending, idv_level: :unsupervised_with_selfie, user: user) + + action + + expect(controller).to redirect_to(idv_verify_by_mail_enter_code_url) + end + end + end + + context 'account is not already verified' do + it 'redirects to have the user verify their account' do + action + expect(controller).to redirect_to(idv_url) + end + + context 'user has a pending profile' do + context 'user has a gpo pending profile' do + let(:user) { create(:profile, :verify_by_mail_pending).user } + + it 'redirects to gpo verify page' do + action + expect(controller).to redirect_to(idv_verify_by_mail_enter_code_url) + end + end + + context 'user has an in person pending profile' do + let(:user) { create(:profile, :in_person_verification_pending).user } + + it 'redirects to in person ready to verify page' do + action + expect(controller).to redirect_to(idv_in_person_ready_to_verify_url) + end + end + + context 'user is under fraud review' do + let(:user) { create(:profile, :fraud_review_pending).user } + + it 'redirects to fraud review page if fraud review is pending' do + action + expect(controller).to redirect_to(idv_please_call_url) + end + end + + context 'user is rejected due to fraud' do + let(:user) { create(:profile, :fraud_rejection).user } + + it 'redirects to fraud rejection page if user is fraud rejected ' do + action + expect(controller).to redirect_to(idv_not_verified_url) + end + end + + context 'user has two pending reasons' do + context 'user has gpo and fraud review pending' do + let(:user) do + create( + :profile, + :verify_by_mail_pending, + :fraud_review_pending, + ).user + end + + it 'redirects to gpo verify page' do + action + expect(controller).to redirect_to(idv_verify_by_mail_enter_code_url) + end + end + + context 'user has gpo and in person pending' do + let(:user) do + create( + :profile, + :verify_by_mail_pending, + :in_person_verification_pending, + ).user + end + + it 'redirects to gpo verify page' do + action + expect(controller).to redirect_to(idv_verify_by_mail_enter_code_url) + end + end + end + end + end + + context 'profile is reset' do + let(:user) { create(:profile, :verified, :password_reset).user } + + it 'redirects to have the user enter their personal key' do + action + expect(controller).to redirect_to(reactivate_account_url) + end + end + end + + context 'with ialmax requested' do + context 'provider is on the ialmax allow list' do + let(:acr_values) { Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } + + before do + allow(IdentityConfig.store).to receive(:allowed_ialmax_providers) { [client_id] } + end + + context 'account is already verified' do + let(:user) do + create( + :profile, :active, :verified, proofing_components: { liveness_check: true } + ).user + end + + it 'redirects to the redirect_uri immediately when pii is unlocked if server-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + + it 'renders client-side redirect to the client app immediately if PII is unlocked and it is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'renders JS client-side redirect to the client app immediately if PII is unlocked and it is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'redirects to the password capture url when pii is locked' do + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(true) + action + + expect(response).to redirect_to(capture_password_url) + end + + it 'tracks IAL2 authentication event' do + stub_analytics + + IdentityLinker.new(user, service_provider).link_identity(ial: 2) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + action + + sp_return_log = SpReturnLog.find_by(issuer: client_id) + expect(sp_return_log.ial).to eq(2) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: false, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid profile', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 0, + billed_ial: 2, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + vtr: nil, + ) + end + end + + context 'account is not already verified' do + it 'redirects to the redirect_uri immediately without proofing if server-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + + it 'renders client-side redirect to the client app immediately if client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + + action + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'renders JS client-side redirect to the client app immediately if JS client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + + action + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'tracks IAL1 authentication event' do + stub_analytics + + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + action + + sp_return_log = SpReturnLog.find_by(issuer: client_id) + expect(sp_return_log.ial).to eq(1) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: false, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid profile', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 0, + billed_ial: 1, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + vtr: nil, + ) + end + end + + context 'profile is reset' do + let(:user) { create(:profile, :verified, :password_reset).user } + + it 'redirects to the redirect_uri immediately without proofing if server-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + + it 'renders client-side redirect to the client app immediately if client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + + action + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'renders JS client-side redirect to the client app immediately if JS client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + + action + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'tracks IAL1 authentication event' do + stub_analytics + + IdentityLinker.new(user, service_provider).link_identity(ial: 1) + user.identities.last.update!( + verified_attributes: %w[given_name family_name birthdate verified_at], + ) + action + + sp_return_log = SpReturnLog.find_by(issuer: client_id) + expect(sp_return_log.ial).to eq(1) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: false, + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid profile', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request handoff', + success: true, + client_id: client_id, + user_sp_authorized: true, + code_digest: kind_of(String), + ) + + expect(@analytics).to have_logged_event( + 'SP redirect initiated', + ial: 0, + billed_ial: 1, + sign_in_flow:, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', + vtr: nil, + ) + end + end + end + end + + context 'user has not approved this application' do + it 'redirects verify shared attributes page' do + action + + expect(response).to redirect_to(sign_up_completed_url) + end + + it 'does not link identity to the user' do + action + expect(user.identities.count).to eq(0) + end + end + + context 'user has already approved this application' do + before do + IdentityLinker.new(user, service_provider).link_identity + user.identities.last.update!(verified_attributes: %w[given_name family_name birthdate]) + end + + it 'redirects back to the client app with a code if client-side redirect is disabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + + redirect_params = UriService.params(response.location) + + expect(redirect_params[:code]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders a client-side redirect back to the client app with a code if it is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + expect(redirect_params[:code]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders a JS client-side redirect back to the client app with a code if it is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + expect(redirect_params[:code]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + end + end + + context 'acr with invalid params that do not interfere with the redirect_uri' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } + + before { params[:prompt] = '' } + + it 'redirects the user with an invalid request if client-side redirect is disabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + + redirect_params = UriService.params(response.location) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders client-side redirect with an invalid request if client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders JS client-side redirect with an invalid request if JS client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'redirects the user with an invalid request if UUID is in server-side redirect list' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'server_side' }) + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + + redirect_params = UriService.params(response.location) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders client-side redirect with an invalid request if UUID is overriden for client-side redirect' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'client_side' }) + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders JS client-side redirect with an invalid request if UUID is overriden for JS client-side redirect' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'client_side_js' }) + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'tracks the event with errors' do + stub_analytics + + action + + expect(SpReturnLog.count).to eq(0) + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: false, + client_id: client_id, + prompt: '', + referer: nil, + allow_prompt_login: true, + unauthorized_scope: true, + errors: hash_including(:prompt), + error_details: hash_including(:prompt), + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: nil, + vtr_param: nil, + ) + + expect(@analytics).to_not have_logged_event('SP redirect initiated') + end + end + + context 'vtr with invalid params that do not interfere with the redirect_uri' do + let(:acr_values) { nil } + let(:vtr) { ['C1'].to_json } + + before { params[:prompt] = '' } + + it 'redirects the user with an invalid request if client-side redirect is disabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + + redirect_params = UriService.params(response.location) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders client-side redirect with an invalid request if client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders JS client-side redirect with an invalid request if JS client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'redirects the user with an invalid request if UUID is in server-side redirect list' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'server_side' }) + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + + redirect_params = UriService.params(response.location) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders client-side redirect with an invalid request if UUID is overriden for client-side redirect' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'client_side' }) + + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders JS client-side redirect with an invalid request if UUID is overriden for JS client-side redirect' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + allow(IdentityConfig.store).to receive(:openid_connect_redirect_uuid_override_map). + and_return({ user.uuid => 'client_side_js' }) + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'tracks the event with errors' do + stub_analytics + + action + + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: false, + client_id: client_id, + prompt: '', + referer: nil, + allow_prompt_login: true, + unauthorized_scope: true, + errors: hash_including(:prompt), + error_details: hash_including(:prompt), + user_fully_authenticated: true, + acr_values: '', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: ['C1'], + vtr_param: '["C1"]', + ) + + expect(@analytics).to_not have_logged_event('SP redirect initiated') expect(SpReturnLog.count).to eq(0) end end - end - context 'user is not signed in' do - context 'without valid acr_values' do - before { params.delete(:acr_values) } + context 'acr with invalid params that mean the redirect_uri is not trusted' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } - it 'handles the error and does not blow up when server-side redirect is enabled' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('server_side') - action + before { params.delete(:client_id) } - expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + it 'renders the error page' do + action + expect(controller).to render_template('openid_connect/authorization/error') end - it 'handles the error and does not blow up when client-side redirect is enabled' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('client_side') + it 'tracks the event with errors' do + stub_analytics + action - expect(controller).to render_template('openid_connect/shared/redirect') - expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) - end + expect(SpReturnLog.count).to eq(0) - it 'handles the error and does not blow up when client-side redirect is enabled' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('client_side_js') - action + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: false, + client_id: nil, + prompt: 'select_account', + referer: nil, + allow_prompt_login: nil, + unauthorized_scope: true, + errors: hash_including(:client_id), + error_details: hash_including(:client_id), + user_fully_authenticated: true, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: nil, + vtr_param: nil, + ) - expect(controller).to render_template('openid_connect/shared/redirect_js') - expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + expect(@analytics).to_not have_logged_event('SP redirect initiated') end end - context 'with a bad redirect_uri' do - before { params[:redirect_uri] = '!!!' } + context 'vtr with invalid params that mean the redirect_uri is not trusted' do + let(:acr_values) { nil } + let(:vtr) { ['C1'].to_json } + + before { params.delete(:client_id) } it 'renders the error page' do action expect(controller).to render_template('openid_connect/authorization/error') end - end - context 'ialmax requested when service provider is not in allowlist' do - before do - params[:acr_values] = Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF - end + it 'tracks the event with errors' do + stub_analytics - it 'redirects the user if server-side redirect is enabled' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('server_side') action - expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + expect(SpReturnLog.count).to eq(0) - redirect_params = UriService.params(response.location) + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: false, + client_id: nil, + prompt: 'select_account', + referer: nil, + allow_prompt_login: nil, + unauthorized_scope: true, + errors: hash_including(:client_id), + error_details: hash_including(:client_id), + user_fully_authenticated: true, + acr_values: '', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: ['C1'], + vtr_param: '["C1"]', + ) - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present - expect(redirect_params[:state]).to eq(params[:state]) + expect(@analytics).to_not have_logged_event('SP redirect initiated') end + end + end - it 'renders a client-side redirect if client-side redirect is enabled' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('client_side') - action + context 'user is not signed in' do + context 'using acr_values' do + let(:vtr) { nil } # purely for emphasis + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } - expect(controller).to render_template('openid_connect/shared/redirect') - expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + context 'without valid acr_values' do + let(:acr_values) { nil } - redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + it 'handles the error and does not blow up when server-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + action - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present - expect(redirect_params[:state]).to eq(params[:state]) + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + + it 'handles the error and does not blow up when client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'handles the error and does not blow up when client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end end - it 'renders a JS client-side redirect if JS client-side redirect is enabled' do - allow(IdentityConfig.store).to receive(:openid_connect_redirect). - and_return('client_side_js') - action + context 'with a bad redirect_uri' do + before { params[:redirect_uri] = '!!!' } - expect(controller).to render_template('openid_connect/shared/redirect_js') - expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + it 'renders the error page' do + action + expect(controller).to render_template('openid_connect/authorization/error') + end + end - redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + context 'ialmax requested when service provider is not in allowlist' do + before do + params[:acr_values] = Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF + end - expect(redirect_params[:error]).to eq('invalid_request') - expect(redirect_params[:error_description]).to be_present - expect(redirect_params[:state]).to eq(params[:state]) + it 'redirects the user if server-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + + redirect_params = UriService.params(response.location) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders a client-side redirect if client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders a JS client-side redirect if JS client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end end - end - it 'redirects to SP landing page with the request_id in the params' do - stub_analytics - expect(@analytics).to receive(:track_event). - with( + it 'redirects to SP landing page with the request_id in the params' do + stub_analytics + + action + sp_request_id = ServiceProviderRequestProxy.last.uuid + + expect(response).to redirect_to new_user_session_url + expect(controller.session[:sp][:request_id]).to eq(sp_request_id) + expect(@analytics).to have_logged_event( 'OpenID Connect: authorization request', success: true, client_id: client_id, @@ -1211,29 +2466,158 @@ service_provider_pkce: nil, scope: 'openid', vtr: nil, - vtr_param: '', + vtr_param: nil, ) + end - action - sp_request_id = ServiceProviderRequestProxy.last.uuid - - expect(response).to redirect_to new_user_session_url - expect(controller.session[:sp][:request_id]).to eq(sp_request_id) + it 'sets sp information in the session and does not transmit ial2 attrs for ial1' do + action + sp_request_id = ServiceProviderRequestProxy.last.uuid + + expect(session[:sp]).to eq( + acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + issuer: 'urn:gov:gsa:openidconnect:test', + request_id: sp_request_id, + request_url: request.original_url, + requested_attributes: %w[], + biometric_comparison_required: false, + vtr: nil, + ) + end end - it 'sets sp information in the session and does not transmit ial2 attrs for ial1' do - action - sp_request_id = ServiceProviderRequestProxy.last.uuid - - expect(session[:sp]).to eq( - acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, - issuer: 'urn:gov:gsa:openidconnect:test', - request_id: sp_request_id, - request_url: request.original_url, - requested_attributes: %w[], - biometric_comparison_required: false, - vtr: nil, - ) + context 'using vot' do + let(:acr_values) { nil } # for emphasis + let(:vtr) { ['C1'].to_json } + + context 'without a valid vtr' do + let(:vtr) { nil } + + it 'handles the error and does not blow up when server-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + end + + it 'handles the error and does not blow up when client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + + it 'handles the error and does not blow up when client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + end + end + + context 'with a bad redirect_uri' do + before { params[:redirect_uri] = '!!!' } + + it 'renders the error page' do + action + expect(controller).to render_template('openid_connect/authorization/error') + end + end + + context 'ialmax requested when service provider is not in allowlist' do + let(:vtr) { ['CaPb'].to_json } + + it 'redirects the user if server-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + + redirect_params = UriService.params(response.location) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders a client-side redirect if client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side') + action + + expect(controller).to render_template('openid_connect/shared/redirect') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + + it 'renders a JS client-side redirect if JS client-side redirect is enabled' do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('client_side_js') + action + + expect(controller).to render_template('openid_connect/shared/redirect_js') + expect(assigns(:oidc_redirect_uri)).to start_with(params[:redirect_uri]) + + redirect_params = UriService.params(assigns(:oidc_redirect_uri)) + + expect(redirect_params[:error]).to eq('invalid_request') + expect(redirect_params[:error_description]).to be_present + expect(redirect_params[:state]).to eq(params[:state]) + end + end + + it 'redirects to SP landing page with the request_id in the params' do + stub_analytics + + action + sp_request_id = ServiceProviderRequestProxy.last.uuid + + expect(response).to redirect_to new_user_session_url + expect(controller.session[:sp][:request_id]).to eq(sp_request_id) + expect(@analytics).to have_logged_event( + 'OpenID Connect: authorization request', + success: true, + client_id: client_id, + prompt: 'select_account', + referer: nil, + allow_prompt_login: true, + errors: {}, + unauthorized_scope: true, + user_fully_authenticated: false, + acr_values: '', + code_challenge_present: false, + service_provider_pkce: nil, + scope: 'openid', + vtr: ['C1'], + vtr_param: ['C1'].to_json, + ) + end + + it 'sets sp information in the session and does not transmit ial2 attrs for ial1' do + action + sp_request_id = ServiceProviderRequestProxy.last.uuid + + expect(session[:sp]).to eq( + acr_values: '', + issuer: 'urn:gov:gsa:openidconnect:test', + request_id: sp_request_id, + request_url: request.original_url, + requested_attributes: %w[], + biometric_comparison_required: false, + vtr: ['C1'], + ) + end end end end diff --git a/spec/decorators/service_provider_session_spec.rb b/spec/decorators/service_provider_session_spec.rb index 8106348ce5b..504b9ffcaa0 100644 --- a/spec/decorators/service_provider_session_spec.rb +++ b/spec/decorators/service_provider_session_spec.rb @@ -179,46 +179,6 @@ end end - describe '#selfie_required' do - before do - expect(FeatureManagement).to receive(:idv_allow_selfie_check?). - and_return(selfie_capture_enabled) - end - - context 'doc_auth_selfie_capture_enabled is true' do - let(:selfie_capture_enabled) { true } - - it 'returns true when sp biometric_comparison_required is true' do - sp_session[:biometric_comparison_required] = true - expect(subject.biometric_comparison_required?).to eq(true) - end - - it 'returns true when sp biometric_comparison_required is truthy' do - sp_session[:biometric_comparison_required] = 1 - expect(subject.biometric_comparison_required?).to eq(true) - end - - it 'returns false when sp biometric_comparison_required is false' do - sp_session[:biometric_comparison_required] = false - expect(subject.biometric_comparison_required?).to eq(false) - end - - it 'returns false when sp biometric_comparison_required is nil' do - sp_session[:biometric_comparison_required] = nil - expect(subject.biometric_comparison_required?).to eq(false) - end - end - - context 'doc_auth_selfie_capture_enabled is false' do - let(:selfie_capture_enabled) { false } - - it 'returns false' do - sp_session[:biometric_comparison_required] = true - expect(subject.biometric_comparison_required?).to eq(false) - end - end - end - describe '#cancel_link_url' do subject(:decorator) do ServiceProviderSession.new(