diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f54f4bdee7..78ae5c5d697 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -33,6 +33,7 @@ class ApplicationController < ActionController::Base prepend_before_action :set_locale before_action :disable_caching before_action :cache_issuer_in_cookie + after_action :store_web_locale_in_session def session_expires_at return if @skip_session_expiration || @skip_session_load @@ -400,6 +401,12 @@ def set_locale I18n.locale = LocaleChooser.new(params[:locale], request).locale end + def store_web_locale_in_session + return unless user_signed_in? + + user_session[:web_locale] = I18n.locale.to_s + end + def pii_requested_but_locked? if resolved_authn_context_result.identity_proofing? || resolved_authn_context_result.ialmax? current_user.identity_verified? && diff --git a/app/presenters/openid_connect_user_info_presenter.rb b/app/presenters/openid_connect_user_info_presenter.rb index b918b74a681..e7aad1f3cfd 100644 --- a/app/presenters/openid_connect_user_info_presenter.rb +++ b/app/presenters/openid_connect_user_info_presenter.rb @@ -20,6 +20,7 @@ def user_info } info[:all_emails] = all_emails_from_sp_identity(identity) if scoper.all_emails_requested? + info[:locale] = web_locale if scoper.locale_requested? info.merge!(ial2_attributes) if identity_proofing_requested_for_verified_user? info.merge!(x509_attributes) if scoper.x509_scopes_requested? info[:verified_at] = verified_at if scoper.verified_at_requested? @@ -57,6 +58,10 @@ def all_emails_from_sp_identity(identity) identity.user.confirmed_email_addresses.map(&:email) end + def web_locale + out_of_band_session_accessor.load_web_locale + end + def ial2_attributes { given_name: stringify_attr(ial2_data.first_name), diff --git a/app/presenters/saml_requested_attributes_presenter.rb b/app/presenters/saml_requested_attributes_presenter.rb index 80ee77dfa5b..763854969a4 100644 --- a/app/presenters/saml_requested_attributes_presenter.rb +++ b/app/presenters/saml_requested_attributes_presenter.rb @@ -4,6 +4,7 @@ class SamlRequestedAttributesPresenter ATTRIBUTE_TO_FRIENDLY_NAME_MAP = { email: :email, all_emails: :all_emails, + locale: :locale, first_name: :given_name, last_name: :family_name, dob: :birthdate, @@ -30,6 +31,7 @@ def requested_attributes else attrs = [:email] attrs << :all_emails if bundle.include?(:all_emails) + attrs << :locale if bundle.include?(:locale) attrs << :verified_at if bundle.include?(:verified_at) attrs end diff --git a/app/services/attribute_asserter.rb b/app/services/attribute_asserter.rb index bba5ac0689e..32eb6222cd4 100644 --- a/app/services/attribute_asserter.rb +++ b/app/services/attribute_asserter.rb @@ -36,6 +36,7 @@ def build attrs = default_attrs add_email(attrs) if bundle.include? :email add_all_emails(attrs) if bundle.include? :all_emails + add_locale(attrs) if bundle.include? :locale add_bundle(attrs) if should_add_proofed_attributes? add_verified_at(attrs) if bundle.include?(:verified_at) && ial2_service_provider? if authn_request.requested_vtr_authn_contexts.present? @@ -208,6 +209,10 @@ def add_email(attrs) } end + def add_locale(attrs) + attrs[:locale] = { getter: ->(_principal) { user_session[:web_locale] } } + end + def add_all_emails(attrs) attrs[:all_emails] = { getter: ->(principal) { principal.confirmed_email_addresses.map(&:email) }, diff --git a/app/services/openid_connect_attribute_scoper.rb b/app/services/openid_connect_attribute_scoper.rb index 0886ab7230c..814180ca9cb 100644 --- a/app/services/openid_connect_attribute_scoper.rb +++ b/app/services/openid_connect_attribute_scoper.rb @@ -20,6 +20,7 @@ class OpenidConnectAttributeScoper VALID_SCOPES = (%w[ email all_emails + locale openid profile:verified_at ] + X509_SCOPES + IAL2_SCOPES).freeze @@ -27,6 +28,7 @@ class OpenidConnectAttributeScoper VALID_IAL1_SCOPES = (%w[ email all_emails + locale openid profile:verified_at ] + X509_SCOPES).freeze @@ -35,6 +37,7 @@ class OpenidConnectAttributeScoper email: %w[email], email_verified: %w[email], all_emails: %w[all_emails], + locale: %w[locale], address: %w[address], phone: %w[phone], phone_verified: %w[phone], @@ -82,6 +85,10 @@ def all_emails_requested? scopes.include?('all_emails') end + def locale_requested? + scopes.include?('locale') + end + def filter(user_info) user_info.select do |key, _v| !ATTRIBUTE_SCOPES_MAP.key?(key) || (scopes & ATTRIBUTE_SCOPES_MAP[key]).present? diff --git a/app/services/out_of_band_session_accessor.rb b/app/services/out_of_band_session_accessor.rb index da497b440dd..cad4749a67c 100644 --- a/app/services/out_of_band_session_accessor.rb +++ b/app/services/out_of_band_session_accessor.rb @@ -39,6 +39,11 @@ def load_pii(profile_id) Pii::Cacher.new(nil, session).fetch(profile_id) if session end + # @return [String, nil] + def load_web_locale + session_data.dig('warden.user.user.session', :web_locale) + end + # @return [X509::Attributes] def load_x509 X509::Attributes.new_from_json(session_data.dig('warden.user.user.session', :decrypted_x509)) @@ -83,6 +88,13 @@ def put_x509(piv_cert_info, expiration = 5.minutes) put(data, expiration) end + # @api private + # Only used for convenience in tests + # @param [String] locale + def put_locale(locale, expiration = 5.minutes) + put({ web_locale: locale }, expiration) + end + # @api private # Only used for convenience in tests def exists? @@ -92,15 +104,16 @@ def exists? private def put(data, expiration = 5.minutes) - session_data = { - 'warden.user.user.session' => data.to_h, + existing_user_session = session_data.dig('warden.user.user.session') + new_session_data = { + 'warden.user.user.session' => data.to_h.merge(existing_user_session.to_h), } session_store.send( :write_session, PLACEHOLDER_REQUEST, Rack::Session::SessionId.new(session_uuid), - session_data, + new_session_data, expire_after: expiration.to_i, ) end diff --git a/spec/controllers/sign_up/passwords_controller_spec.rb b/spec/controllers/sign_up/passwords_controller_spec.rb index e0979324944..6b6769b0bf2 100644 --- a/spec/controllers/sign_up/passwords_controller_spec.rb +++ b/spec/controllers/sign_up/passwords_controller_spec.rb @@ -94,6 +94,7 @@ 'last_request_at' => kind_of(Numeric), new_device: false, in_account_creation_flow: true, + web_locale: 'en', ) end end diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 7a113b24b0f..f743a6227a8 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -569,6 +569,39 @@ expect(userinfo_response[:verified_at]).to be_nil end + it 'returns the locale if requested', driver: :mobile_rack_test do + user = user_with_2fa + + token_response = sign_in_get_token_response( + user: user, + scope: 'openid email locale', + sign_in_steps: proc do + visit root_path(locale: 'es') + + fill_in_credentials_and_submit( + user.confirmed_email_addresses.first.email, + user.password, + ) + fill_in_code_with_last_phone_otp + click_submit_default + end, + handoff_page_steps: proc do + click_agree_and_continue + end, + ) + + access_token = token_response[:access_token] + expect(access_token).to be_present + + page.driver.get api_openid_connect_userinfo_path, + {}, + 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" + + userinfo_response = JSON.parse(page.body).with_indifferent_access + expect(userinfo_response[:email]).to eq(user.email) + expect(userinfo_response[:locale]).to eq('es') + end + it 'errors if verified_within param is too recent', driver: :mobile_rack_test do client_id = 'urn:gov:gsa:openidconnect:test' allow(IdentityConfig.store).to receive(:allowed_verified_within_providers) @@ -1087,6 +1120,7 @@ def sign_in_get_token_response( acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, scope: 'openid email', redirect_uri: 'gov.gsa.openidconnect.test://result', + sign_in_steps: nil, handoff_page_steps: nil, proofing_steps: nil, verified_within: nil, @@ -1114,7 +1148,11 @@ def sign_in_get_token_response( verified_within: verified_within, ) - _user = sign_in_live_with_2fa(user) + if sign_in_steps.present? + sign_in_steps.call + else + sign_in_live_with_2fa(user) + end proofing_steps&.call handoff_page_steps&.call diff --git a/spec/features/saml/ial1_sso_spec.rb b/spec/features/saml/ial1_sso_spec.rb index bc73dbf0abe..5fd8a166e48 100644 --- a/spec/features/saml/ial1_sso_spec.rb +++ b/spec/features/saml/ial1_sso_spec.rb @@ -238,4 +238,35 @@ expect(xmldoc.attribute_value_for('verified_at')).to be_blank end end + + context 'requesting locale' do + it 'includes locale in the response' do + user = create(:user, :fully_registered) + saml_authn_request = saml_authn_request_url( + overrides: { + issuer: sp1_issuer, + authn_context: [ + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + "#{Saml::Idp::Constants::REQUESTED_ATTRIBUTES_CLASSREF}email,locale", + ], + }, + ) + + visit saml_authn_request + visit root_url(locale: 'es') + fill_in_credentials_and_submit(user.email, user.password) + fill_in_code_with_last_phone_otp + click_submit_default + + expect(current_url).to match new_user_session_path + click_submit_default + click_agree_and_continue + click_submit_default + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + + expect(xmldoc.attribute_node_for('locale')).to be_present + expect(xmldoc.attribute_value_for('locale')).to eq('es') + end + end end diff --git a/spec/presenters/openid_connect_user_info_presenter_spec.rb b/spec/presenters/openid_connect_user_info_presenter_spec.rb index 2af4dc32ab0..2237272d621 100644 --- a/spec/presenters/openid_connect_user_info_presenter_spec.rb +++ b/spec/presenters/openid_connect_user_info_presenter_spec.rb @@ -5,7 +5,7 @@ let(:rails_session_id) { SecureRandom.uuid } let(:scope) do - 'openid email all_emails address phone profile social_security_number x509' + 'openid email all_emails locale address phone profile social_security_number x509' end let(:service_provider_ial) { 2 } let(:service_provider) { create(:service_provider, ial: service_provider_ial) } @@ -13,6 +13,7 @@ let(:vtr) { ['C1.C2.P1'] } let(:acr_values) { nil } let(:requested_aal_value) { nil } + let(:locale) { 'en' } let(:identity) do build( :service_provider_identity, @@ -26,6 +27,10 @@ ) end + before do + OutOfBandSessionAccessor.new(rails_session_id).put_locale(locale, 5.minutes.in_seconds) + end + subject(:presenter) { OpenidConnectUserInfoPresenter.new(identity) } describe '#user_info' do @@ -61,7 +66,7 @@ context 'no identity proofing' do let(:vtr) { ['C1.C2'] } - let(:scope) { 'openid email all_emails' } + let(:scope) { 'openid email all_emails locale' } it 'includes the correct attributes' do aggregate_failures do @@ -70,6 +75,7 @@ expect(user_info[:email]).to eq(identity.user.email_addresses.first.email) expect(user_info[:email_verified]).to eq(true) expect(user_info[:all_emails]).to eq([identity.user.email_addresses.first.email]) + expect(user_info[:locale]).to eq(locale) expect(user_info).to_not have_key(:ial) expect(user_info).to_not have_key(:aal) expect(user_info[:vot]).to eq('C1.C2') @@ -79,7 +85,9 @@ context 'identity proofing' do let(:vtr) { ['C1.C2.P1'] } - let(:scope) { 'openid email all_emails address phone profile social_security_number' } + let(:scope) do + 'openid email all_emails locale address phone profile social_security_number' + end it 'includes the correct non-proofed attributes' do aggregate_failures do @@ -88,6 +96,7 @@ expect(user_info[:email]).to eq(identity.user.email_addresses.first.email) expect(user_info[:email_verified]).to eq(true) expect(user_info[:all_emails]).to eq([identity.user.email_addresses.first.email]) + expect(user_info[:locale]).to eq(locale) expect(user_info).to_not have_key(:ial) expect(user_info).to_not have_key(:aal) expect(user_info[:vot]).to eq('C1.C2.P1') @@ -126,7 +135,7 @@ ].join(' ') end let(:requested_aal_value) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } - let(:scope) { 'openid email all_emails' } + let(:scope) { 'openid email all_emails locale' } it 'includes the correct attributes' do aggregate_failures do @@ -135,6 +144,7 @@ expect(user_info[:email]).to eq(identity.user.email_addresses.first.email) expect(user_info[:email_verified]).to eq(true) expect(user_info[:all_emails]).to eq([identity.user.email_addresses.first.email]) + expect(user_info[:locale]).to eq(locale) expect(user_info[:ial]).to eq(Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF) expect(user_info[:aal]).to eq(Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF) expect(user_info).to_not have_key(:vot) @@ -150,7 +160,9 @@ ].join(' ') end let(:requested_aal_value) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } - let(:scope) { 'openid email all_emails address phone profile social_security_number' } + let(:scope) do + 'openid email all_emails locale address phone profile social_security_number' + end it 'includes the correct non-proofed attributes' do aggregate_failures do @@ -159,6 +171,7 @@ expect(user_info[:email]).to eq(identity.user.email_addresses.first.email) expect(user_info[:email_verified]).to eq(true) expect(user_info[:all_emails]).to eq([identity.user.email_addresses.first.email]) + expect(user_info[:locale]).to eq(locale) expect(user_info[:ial]).to eq(Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF) expect(user_info[:aal]).to eq(Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF) expect(user_info).to_not have_key(:vot) @@ -199,6 +212,7 @@ expect(user_info[:email]).to eq(identity.user.email_addresses.first.email) expect(user_info[:email_verified]).to eq(true) expect(user_info[:all_emails]).to eq([identity.user.email_addresses.first.email]) + expect(user_info[:locale]).to eq(locale) expect(user_info[:ial]).to eq( Saml::Idp::Constants::IAL2_BIO_REQUIRED_AUTHN_CONTEXT_CLASSREF, ) @@ -236,7 +250,9 @@ ].join(' ') end let(:requested_aal_value) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } - let(:scope) { 'openid email all_emails address phone profile social_security_number' } + let(:scope) do + 'openid email all_emails locale address phone profile social_security_number' + end context 'the user has verified their identity' do it 'includes the proofed attributes' do @@ -286,7 +302,7 @@ context 'when minimal scopes are requested for proofed attributes' do let(:scope) do - 'openid email all_emails profile' + 'openid email all_emails locale profile' end it 'only returns the requested attributes' do diff --git a/spec/services/attribute_asserter_spec.rb b/spec/services/attribute_asserter_spec.rb index 59055c719aa..d240451da3d 100644 --- a/spec/services/attribute_asserter_spec.rb +++ b/spec/services/attribute_asserter_spec.rb @@ -7,7 +7,7 @@ let(:facial_match_verified_user) do create(:profile, :active, :verified, idv_level: :in_person).user end - let(:user_session) { {} } + let(:user_session) { { web_locale: 'en' } } let(:identity) do build( :service_provider_identity, @@ -615,6 +615,19 @@ end end + context 'custom bundle includes locale' do + let(:attribute_bundle) { %w[locale] } + before do + subject.build + end + + it 'includes the user locale' do + locale_getter = user.asserted_attributes[:locale][:getter] + locale = locale_getter.call(user) + expect(locale).to eq('en') + end + end + context 'custom bundle includes email, phone' do let(:attribute_bundle) { %w[first_name last_name email phone] } before do diff --git a/spec/services/openid_connect_attribute_scoper_spec.rb b/spec/services/openid_connect_attribute_scoper_spec.rb index facbf222fee..957d443e0cf 100644 --- a/spec/services/openid_connect_attribute_scoper_spec.rb +++ b/spec/services/openid_connect_attribute_scoper_spec.rb @@ -35,6 +35,7 @@ email: 'foo@example.com', email_verified: true, all_emails: ['foo@example.com', 'bar@example.com'], + locale: 'es', given_name: 'John', family_name: 'Jones', birthdate: '1970-01-01', @@ -89,6 +90,14 @@ end end + context 'with the locale scope' do + let(:scope) { 'openid locale' } + + it 'includes the locale attribute' do + expect(filtered[:locale]).to eq('es') + end + end + context 'with phone scope' do let(:scope) { 'openid phone' } diff --git a/spec/services/out_of_band_session_accessor_spec.rb b/spec/services/out_of_band_session_accessor_spec.rb index 68f414ab2be..0a3fb23d5c7 100644 --- a/spec/services/out_of_band_session_accessor_spec.rb +++ b/spec/services/out_of_band_session_accessor_spec.rb @@ -4,6 +4,11 @@ let(:session_uuid) { SecureRandom.uuid } let(:profile_id) { 123 } + # This test uses a separate writer instance to write test data to the session store. + # The OutOfBandSessionAccessor memoizes the data that it reads from the session. + # Writes require reads to merge test data properly for subsequent writes. + subject(:writer_instance) { described_class.new(session_uuid) } + subject(:store) { described_class.new(session_uuid) } around do |ex| @@ -24,9 +29,18 @@ end end + describe '#load_web_locale' do + it 'loads the web_locale from the session' do + writer_instance.put_locale('es') + + web_locale = store.load_web_locale + expect(web_locale).to eq('es') + end + end + describe '#load_pii' do it 'loads PII from the session' do - store.put_pii( + writer_instance.put_pii( profile_id: profile_id, pii: { dob: '1970-01-01' }, expiration: 5.minutes.in_seconds, @@ -40,7 +54,7 @@ describe '#load_x509' do it 'loads X509 attributes from the session' do - store.put_x509({ subject: 'O=US, OU=DoD, CN=John.Doe.1234' }, 5.minutes.in_seconds) + writer_instance.put_x509({ subject: 'O=US, OU=DoD, CN=John.Doe.1234' }, 5.minutes.in_seconds) x509 = store.load_x509 expect(x509).to be_kind_of(X509::Attributes) @@ -50,7 +64,7 @@ describe '#destroy' do it 'destroys the session' do - store.put_pii( + writer_instance.put_pii( profile_id: profile_id, pii: { first_name: 'Fakey' }, expiration: 5.minutes.in_seconds,