diff --git a/docker-compose.portals.yml b/docker-compose.portals.yml index 22d8d27cb0..c394d9efcf 100644 --- a/docker-compose.portals.yml +++ b/docker-compose.portals.yml @@ -142,7 +142,6 @@ services: - DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true - CPI_API_GW_BASE_URL=http://localhost:4567/ - CMS_IDM_OAUTH_URL=http://localhost:4567/ - - IDP_HOST=idp.int.identitysandbox.gov - RUBY_YJIT_ENABLE=1 - ENV=local - NEW_RELIC_MONITOR_MODE=false diff --git a/dpc-portal/.env.test b/dpc-portal/.env.test new file mode 100644 index 0000000000..87bc2955dd --- /dev/null +++ b/dpc-portal/.env.test @@ -0,0 +1,20 @@ +# Application settings +DATABASE_URL=postgresql://localhost:5432/dpc-portal_development +TEST_DATABASE_URL=postgresql://localhost:5432/dpc-portal_test +GOLDEN_MACAROON=${GOLDEN_MACAROON} +API_METADATA_URL=http://localhost:3002/api/v1 +API_ADMIN_URL=http://localhost:9900 +DB_USER=postgres +DB_PASS=dpc-safe +DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true +CPI_API_GW_BASE_URL=http://localhost:4567/ +CMS_IDM_OAUTH_URL=http://localhost:4567/ +IDP_ID_ME_HOST=api.idmelabs.com +IDP_LOGIN_DOT_GOV_HOST=idp.int.identitysandbox.gov +RUBY_YJIT_ENABLE=1 +ENV=local +RAILS_ENV=development +NEW_RELIC_MONITOR_MODE=false +DISABLE_JSON_LOGGER=true +RAILS_DEVELOPMENT_HOSTS=host.docker.internal +SKIP_SIMPLE_COV=${SKIP_SIMPLE_COV:-} diff --git a/dpc-portal/.rspec b/dpc-portal/.rspec index 0b50daa053..9c412952b7 100644 --- a/dpc-portal/.rspec +++ b/dpc-portal/.rspec @@ -1,2 +1,4 @@ --require spec_helper --order rand +-I . +-I spec \ No newline at end of file diff --git a/dpc-portal/Gemfile b/dpc-portal/Gemfile index 8d893301e1..89f8a86d21 100644 --- a/dpc-portal/Gemfile +++ b/dpc-portal/Gemfile @@ -38,7 +38,7 @@ gem 'macaroons' gem 'net-imap', '>= 0.5.14' gem 'newrelic_rpm', '~> 8.10' gem 'nokogiri', '>= 1.19.3' -gem 'omniauth_openid_connect' +gem 'omniauth_openid_connect', '~> 0.8.0' gem 'omniauth-rails_csrf_protection' gem 'pg', '>= 0.18', '< 2.0' gem 'puma', '~> 6.4.3' @@ -80,9 +80,13 @@ group :development do gem 'rubocop-performance', require: false # Version 0.18 has a breaking change for sonarqube + gem 'debug', '~> 1.6.0', require: false + gem 'httplog' gem 'simplecov', '<= 0.17' gem 'spring' gem 'spring-watcher-listen', '~> 2.1.0' + + gem 'ruby-lsp-rspec' end group :test do diff --git a/dpc-portal/Gemfile.lock b/dpc-portal/Gemfile.lock index 40429c22bc..a68b97bec3 100644 --- a/dpc-portal/Gemfile.lock +++ b/dpc-portal/Gemfile.lock @@ -167,6 +167,9 @@ GEM addressable date (3.5.1) date_time_precision (0.8.1) + debug (1.6.3) + irb (>= 1.3.6) + reline (>= 0.3.1) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) diff-lcs (1.5.1) @@ -217,6 +220,10 @@ GEM railties (>= 5.0) htmlbeautifier (1.4.3) htmlentities (4.3.4) + httplog (1.8.0) + benchmark + rack (>= 2.0) + rainbow (>= 2.0.0) i18n (1.14.8) concurrent-ruby (~> 1.0) ice_nine (0.11.2) @@ -347,7 +354,7 @@ GEM omniauth_openid_connect (0.8.0) omniauth (>= 1.9, < 3) openid_connect (~> 2.2) - openid_connect (2.3.0) + openid_connect (2.3.1) activemodel attr_required (>= 1.0.0) email_validator @@ -387,7 +394,7 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.6) - rack-oauth2 (2.2.1) + rack-oauth2 (2.3.0) activesupport attr_required faraday (~> 2.0) @@ -434,6 +441,10 @@ GEM ffi rbnacl-libsodium (1.0.16) rbnacl (>= 3.0.1) + rbs (4.0.2) + logger + prism (>= 1.6.0) + tsort rdoc (7.2.0) erb psych (>= 4.0.0) @@ -481,6 +492,12 @@ GEM rubocop-performance (1.23.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) + ruby-lsp (0.26.9) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) + ruby-lsp-rspec (0.1.29) + ruby-lsp (~> 0.26.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -610,12 +627,14 @@ DEPENDENCIES byebug capybara climate_control + debug (~> 1.6.0) dotenv-rails factory_bot_rails fakefs faraday (>= 2.14.2) fhir_models health_check + httplog jbuilder (~> 2.7) json-jwt (>= 1.16.6) jwt (>= 3.2.0) @@ -628,7 +647,7 @@ DEPENDENCIES newrelic_rpm (~> 8.10) nokogiri (>= 1.19.3) omniauth-rails_csrf_protection - omniauth_openid_connect + omniauth_openid_connect (~> 0.8.0) pg (>= 0.18, < 2.0) pg-aws_rds_iam pry @@ -644,6 +663,7 @@ DEPENDENCIES rspec-rails rubocop rubocop-performance + ruby-lsp-rspec sassc-rails (>= 2.1.2) selenium-webdriver simplecov (<= 0.17) diff --git a/dpc-portal/app/components/page/utility/error_component.html.erb b/dpc-portal/app/components/page/utility/error_component.html.erb index 94190c36c4..67f4526e6e 100644 --- a/dpc-portal/app/components/page/utility/error_component.html.erb +++ b/dpc-portal/app/components/page/utility/error_component.html.erb @@ -18,20 +18,20 @@ <% when :cd_accepted %> <%= link_to 'Go to DPC home', root_url, class: 'usa-button margin-right-0' %> <% when :pii_mismatch %> - <%= render Core::Button::ButtonComponent.new(label: "Sign out of Login.gov", - destination: login_dot_gov_logout_path(invitation_id: @invitation.id), + <%= render Core::Button::ButtonComponent.new(label: "Sign out of CSP", + destination: csp_logout_path(invitation_id: @invitation.id), method: :delete) %> <% when :email_mismatch %> - <%= render Core::Button::ButtonComponent.new(label: "Sign out of Login.gov", - destination: login_dot_gov_logout_path(invitation_id: @invitation.id), + <%= render Core::Button::ButtonComponent.new(label: "Sign out of CSP", + destination: csp_logout_path(invitation_id: @invitation.id), method: :delete) %> <% when :no_account %> - <%= render Core::Button::ButtonComponent.new(label: "Sign out of Login.gov", - destination: login_dot_gov_logout_path, + <%= render Core::Button::ButtonComponent.new(label: "Sign out of CSP", + destination: csp_logout_path, method: :delete) %> - <% when :login_gov_signin_cancel %> + <% when :csp_signin_cancel %> <%= link_to 'Back to portal home', sign_in_path, class: 'usa-button usa-button--outline', data: { turbo: false }%> - <% when :login_gov_signin_fail %> + <% when :csp_signin_fail %> <%= link_to 'Back to portal home', sign_in_path, class: 'usa-button usa-button--outline', data: { turbo: false }%> <% end %> diff --git a/dpc-portal/app/controllers/application_controller.rb b/dpc-portal/app/controllers/application_controller.rb index 69f2003f66..8cb92eaf2e 100644 --- a/dpc-portal/app/controllers/application_controller.rb +++ b/dpc-portal/app/controllers/application_controller.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true # Parent class of all controllers +# rubocop:disable Metrics/ClassLength class ApplicationController < ActionController::Base - IDP_HOST = ENV.fetch('IDP_HOST') - IDP_CLIENT_ID = "urn:gov:cms:openidconnect.profiles:sp:sso:cms:dpc:#{ENV.fetch('ENV')}".freeze - before_action :check_session_length before_action :set_current_request_attributes before_action :no_store @@ -27,8 +25,9 @@ def authenticate_user! redirect_to sign_in_path end - def sign_in(user) - session['user'] = user.id + def sign_in(user:, csp:) + session[:user] = user.id + session[:csp] = csp end private @@ -50,17 +49,38 @@ def tos_accepted end end + def url_for_logout(csp) + case csp + when :id_me.to_s + url_for_id_me_logout + when :login_dot_gov.to_s + url_for_login_dot_gov_logout + else + raise "Unsupported CSP: #{csp}" + end + end + # Documentation at https://developers.login.gov/oidc/logout/ def url_for_login_dot_gov_logout state = SecureRandom.hex(16) session['omniauth.state'] = state - URI::HTTPS.build(host: IDP_HOST, - path: '/openid_connect/logout', - query: { client_id: IDP_CLIENT_ID, + csp_config = CspConfig.for(:login_dot_gov) + URI::HTTPS.build(host: csp_config.host, + path: csp_config.log_out_path, + query: { client_id: csp_config.identifier, post_logout_redirect_uri: "#{root_url}auth/logged_out", state: }.to_query) end + def url_for_id_me_logout + state = SecureRandom.hex(16) + session['omniauth.state'] = state + URI::HTTPS.build(host: CspConfig.for(:id_me).host, + path: CspConfig.for(:id_me).log_out_path, + query: { client_id: CspConfig.for(:id_me).identifier, + redirect_uri: "#{root_url}oauth/logged_out" }.to_query) + end + # rubocop:disable Metrics/AbcSize def check_session_length session[:logged_in_at] = Time.now if session[:logged_in_at].nil? @@ -133,3 +153,4 @@ def log_credential_action(credential_type, dpc_api_credential_id, action) logger.error(['CredentialAuditLog failure', { action:, credential_type:, dpc_api_credential_id: }]) end end +# rubocop:enable Metrics/ClassLength diff --git a/dpc-portal/app/controllers/csp_controller.rb b/dpc-portal/app/controllers/csp_controller.rb new file mode 100644 index 0000000000..b52d5d7950 --- /dev/null +++ b/dpc-portal/app/controllers/csp_controller.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +# Base controller to handle interactions with CSPs. +# rubocop:disable Metrics/ClassLength +class CspController < ApplicationController + skip_before_action :verify_authenticity_token, only: :openid_connect + + def openid_connect + auth = request.env['omniauth.auth'] + return unless (active_csp = csp(auth.provider)) + + csp_user = CspUser.find_by(uuid: auth.uid, csp: active_csp) + user = csp_user&.user + sign_in_and_log(user, auth.provider) + ial_2_actions(user, auth) + update_email(csp_user, user_emails(auth)) + redirect_to path(user, auth) + end + + def no_account + render(Page::Utility::ErrorComponent.new(nil, 'no_account'), status: :forbidden) + end + + def failure + invitation_flow_match = session[:user_return_to]&.match(%r{/organizations/([0-9]+)/invitations/([0-9]+)}) + if invitation_flow_match + handle_invitation_flow_failure(invitation_flow_match[2]) + elsif params[:code] + logger.error 'CSP Configuration error' + render(Page::Utility::ErrorComponent.new(nil, 'csp_signin_fail')) + else + Rails.logger.info(['User cancelled login', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::UserCancelledLogin }]) + render(Page::Utility::ErrorComponent.new(nil, 'csp_signin_cancel')) + end + end + + def logout + if params[:invitation_id].present? + invitation = Invitation.find(params[:invitation_id]) + session[:user_return_to] = organization_invitation_url(invitation.provider_organization.id, invitation.id) + end + + redirect_to url_for_logout(session[:csp]), allow_other_host: true + end + + private + + def sign_in_and_log(user, csp) + return unless user + + sign_in(user:, csp:) + session[:logged_in_at] = Time.now + Rails.logger.info(['User logged in', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::UserLoggedIn }]) + end + + def handle_invitation_flow_failure(invitation_id) + Rails.logger.info(['Failed invitation flow', + { actionContext: LoggingConstants::ActionContext::Registration, + actionType: LoggingConstants::ActionType::FailedLogin }]) + invitation = Invitation.find(invitation_id) + if invitation.credential_delegate? + render(Page::Utility::ErrorComponent.new(invitation, 'fail_to_proof'), status: :forbidden) + else + render(Page::Invitations::AoFlowFailComponent.new(invitation, 'fail_to_proof', 1), status: :forbidden) + end + end + + def user_emails(auth) + auth.extra.raw_info.all_emails + end + + def maybe_update_user(user, data) + user&.update(given_name: data.given_name, family_name: data.family_name) + end + + def update_email(csp_user, new_emails) + return unless csp_user + + existing_emails = csp_user.user_emails + + # Scan through all of the email from the CSP and add or update as necessary. + ActiveRecord::Base.transaction do + add_or_activate_new_email(csp_user, new_emails, existing_emails) + deactivate_old_email(new_emails, existing_emails) + end + end + + def add_or_activate_new_email(csp_user, new_emails, existing_emails) + new_emails&.each do |new_email| + existing_email = existing_emails.find do |existing_email| + existing_email.email == new_email + end + + if existing_email.nil? + # Add this email + UserEmail.create!(csp_user:, email: new_email, active: true) + else + # Potentially activate this email + activate_email(existing_email) + end + end + end + + def deactivate_old_email(new_emails, existing_emails) + # If an existing email is no longer in the list provided by the CSP, deactivate it. + existing_emails&.each do |existing_email| + unless new_emails&.include?(existing_email.email) + existing_email.update!(active: false, deactivated_at: Time.current, reactivated_at: nil) + end + end + end + + def activate_email(user_email) + return unless user_email.active == false + + user_email.update!(active: true, deactivated_at: nil, reactivated_at: Time.current) + end + + def path(user, auth) + if user.blank? && ial_1_user?(auth) + + Rails.logger.info(['User logged in without account', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::UserLoginWithoutAccount }]) + return no_account_url + end + session.delete(:user_return_to) || organizations_path + end + + def csp(name) + active_csp = Csp.active.find_by(name:) + return active_csp if active_csp + + Rails.logger.info(['User attempted to login but no active CSP found', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::InvalidCsp }]) + render(Page::Utility::ErrorComponent.new(nil, 'csp_signin_fail')) + nil + end + + def ial_2_actions(user, auth) + return if ial_1_user?(auth) # TODO: are 1 and 2 the only options for levels? + + update_csp_tokens(auth) + maybe_update_user(user, auth.extra.raw_info) + end + + def update_csp_tokens(auth) + session[:csp] = auth.provider + session["#{auth.provider}_token"] = auth.credentials.token + session["#{auth.provider}_token_exp"] = auth.credentials.expires_in.seconds.from_now + end +end + +# Abstract methods for specific CSPs +def not_implemented(method) = raise NotImplementedError, "Method not implemented: #{method}" +def name = not_implemented('name') +def display_name = not_implemented('display_name') +def ial_1_user?(_auth) = true +# rubocop:enable Metrics/ClassLength diff --git a/dpc-portal/app/controllers/id_me_controller.rb b/dpc-portal/app/controllers/id_me_controller.rb new file mode 100644 index 0000000000..607f7bfa20 --- /dev/null +++ b/dpc-portal/app/controllers/id_me_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Handles interactions with ID.me +class IdMeController < CspController + def name = :id_me + def display_name = 'ID.me' + + def ial_1_user?(auth) + auth.extra.raw_info.identity_assurance_level == 1 + end + + def user_emails(auth) + [auth.info.email] + end +end diff --git a/dpc-portal/app/controllers/invitations_controller.rb b/dpc-portal/app/controllers/invitations_controller.rb index 4d314a18c4..d23184d9d7 100644 --- a/dpc-portal/app/controllers/invitations_controller.rb +++ b/dpc-portal/app/controllers/invitations_controller.rb @@ -54,6 +54,7 @@ def confirm_cd end # Everybody + # rubocop:disable Metrics/AbcSize def register unless session["invitation_status_#{@invitation.id}"] == 'verification_complete' return redirect_to organization_invitation_url(@organization, @invitation) @@ -62,7 +63,7 @@ def register return unless create_link session.delete("invitation_status_#{@invitation.id}") - sign_in(@user) + sign_in(user: @user, csp: session[:csp]) Rails.logger.info(['User logged in', { actionContext: LoggingConstants::ActionContext::Registration, actionType: LoggingConstants::ActionType::UserLoggedIn, @@ -71,6 +72,7 @@ def register rescue UserInfoServiceError => e handle_user_info_service_error(e, 2) end + # rubocop:enable Metrics/AbcSize def login login_session @@ -78,13 +80,14 @@ def login { actionContext: LoggingConstants::ActionContext::Registration, actionType: LoggingConstants::ActionType::BeginLogin, invitation: @invitation.id }]) - url = URI::HTTPS.build(host: IDP_HOST, - path: '/openid_connect/authorize', - query: { acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', - client_id: IDP_CLIENT_ID, - redirect_uri: "#{my_protocol_host}/auth/login_dot_gov/callback", + puts "SESSION CSP: #{session[:csp].class}:#{session[:csp]}" + csp_config = CspConfig.for(session[:csp]) + url = URI::HTTPS.build(host: csp_config.host, + path: '/oauth/authorize', + query: { client_id: csp_config.identifier, + redirect_uri: "#{my_protocol_host}/auth/#{session[:csp]}/callback", response_type: 'code', - scope: 'openid email all_emails profile social_security_number', + scope: 'openid http://idmanagement.gov/ns/assurance/ial/2/aal/2', nonce: @nonce, state: @state }.to_query) redirect_to url, allow_other_host: true @@ -100,8 +103,9 @@ def renew end def set_idp_token - session[:login_dot_gov_token] = 'token' - session[:login_dot_gov_token_exp] = 2.days.from_now + csp = session[:csp] + session["#{csp}_token"] = 'token' + session["#{csp}_token_exp"] = 2.days.from_now head :ok end @@ -211,7 +215,12 @@ def user user_info = UserInfoService.new.user_info(session) find_or_create_user(user_info) csp = Csp.find_by(name: @user.provider) - CspUser.find_or_create_by!(user: @user, csp: csp, uuid: user_info['sub']) + csp_user = CspUser.find_or_create_by!(user: @user, csp:, uuid: user_info['sub']) + + # Update emails based upon the latest information in user info. + new_emails = user_info['all_emails'] || user_info['emails'] || user_info['emails_confirmed'] + csp_user.add_or_activate_new_email(new_emails) + csp_user.deactivate_old_email(new_emails) update_user(user_info) @user end @@ -247,8 +256,7 @@ def assign_user_attributes(user_to_create, user_info) user_to_create.family_name = user_info['family_name'] user_to_create.pac_id = session.delete(:user_pac_id) - # For now we force login.gov, this will have to change once we support multi-CSP. - user_to_create.provider = :login_dot_gov + user_to_create.provider = session[:csp] || :login_dot_gov user_to_create.uid = user_info['sub'] end @@ -308,9 +316,11 @@ def verify_cd_invitation end def check_for_token - if session[:login_dot_gov_token].present? && - session[:login_dot_gov_token_exp].present? && - session[:login_dot_gov_token_exp] > Time.now + csp = session[:csp] + if csp && !csp.empty? && + session["#{csp}_token"].present? && + session["#{csp}_token_exp"].present? && + session["#{csp}_token_exp"] > Time.now return end diff --git a/dpc-portal/app/controllers/login_dot_gov_controller.rb b/dpc-portal/app/controllers/login_dot_gov_controller.rb index e75c6d8a9f..2671a2f6ff 100644 --- a/dpc-portal/app/controllers/login_dot_gov_controller.rb +++ b/dpc-portal/app/controllers/login_dot_gov_controller.rb @@ -1,159 +1,11 @@ # frozen_string_literal: true # Handles interactions with login.gov. -# This class is > 100 lines and my attempts to refactor made it uglier and too complex to pass the ABC -# check, so I disabled the class length check. When we create controllers for the other CSPs we can pull -# out common code and turn the check back on. +class LoginDotGovController < CspController + def name = :login_dot_gov + def display_name = 'Login.gov' -# rubocop:disable Metrics/ClassLength -class LoginDotGovController < ApplicationController - skip_before_action :verify_authenticity_token, only: :openid_connect - - def openid_connect - auth = request.env['omniauth.auth'] - return unless (csp = csp()) - - csp_user = CspUser.find_by(uuid: auth.uid, csp:) - - user = csp_user&.user - sign_in_and_log(user) - post_signin_actions(user, csp_user, auth) - redirect_to path(user, auth) - end - - def no_account - render(Page::Utility::ErrorComponent.new(nil, 'no_account'), status: :forbidden) - end - - def failure - invitation_flow_match = session[:user_return_to]&.match(%r{/organizations/([0-9]+)/invitations/([0-9]+)}) - if invitation_flow_match - handle_invitation_flow_failure(invitation_flow_match[2]) - elsif params[:code] - logger.error 'Login.gov Configuration error' - render(Page::Utility::ErrorComponent.new(nil, 'login_gov_signin_fail')) - else - Rails.logger.info(['User cancelled login', - { actionContext: LoggingConstants::ActionContext::Authentication, - actionType: LoggingConstants::ActionType::UserCancelledLogin }]) - render(Page::Utility::ErrorComponent.new(nil, 'login_gov_signin_cancel')) - end - end - - def logout - if params[:invitation_id].present? - invitation = Invitation.find(params[:invitation_id]) - session[:user_return_to] = organization_invitation_url(invitation.provider_organization.id, invitation.id) - end - - redirect_to url_for_login_dot_gov_logout, allow_other_host: true - end - - private - - def sign_in_and_log(user) - return unless user - - sign_in(user) - session[:logged_in_at] = Time.now - Rails.logger.info(['User logged in', - { actionContext: LoggingConstants::ActionContext::Authentication, - actionType: LoggingConstants::ActionType::UserLoggedIn }]) - end - - def handle_invitation_flow_failure(invitation_id) - Rails.logger.info(['Failed invitation flow', - { actionContext: LoggingConstants::ActionContext::Registration, - actionType: LoggingConstants::ActionType::FailedLogin }]) - invitation = Invitation.find(invitation_id) - if invitation.credential_delegate? - render(Page::Utility::ErrorComponent.new(invitation, 'fail_to_proof'), status: :forbidden) - else - render(Page::Invitations::AoFlowFailComponent.new(invitation, 'fail_to_proof', 1), status: :forbidden) - end - end - - def maybe_update_user(user, data) - user&.update(given_name: data.given_name, family_name: data.family_name) - end - - def update_email(csp_user, new_emails) - return unless csp_user - - existing_emails = csp_user.user_emails - - # Scan through all of the email from the CSP and add or update as necesssary. - ActiveRecord::Base.transaction do - add_or_activate_new_email(csp_user, new_emails, existing_emails) - deactivate_old_email(new_emails, existing_emails) - end - end - - def add_or_activate_new_email(csp_user, new_emails, existing_emails) - new_emails&.each do |new_email| - existing_email = existing_emails.find do |existing_email| - existing_email.email == new_email - end - - if existing_email.nil? - # Add this email - UserEmail.create!(csp_user:, email: new_email, active: true) - else - # Potentially activate this email - activate_email(existing_email) - end - end - end - - def deactivate_old_email(new_emails, existing_emails) - # If an existing email is no longer in the list provided by the CSP, deactivate it. - existing_emails&.each do |existing_email| - unless new_emails&.include?(existing_email.email) - existing_email.update!(active: false, deactivated_at: Time.current, reactivated_at: nil) - end - end - end - - def activate_email(user_email) - return unless user_email.active == false - - user_email.update!(active: true, deactivated_at: nil, reactivated_at: Time.current) - end - - def ial_2_actions(user, auth) - data = auth.extra.raw_info - - return unless data.ial == 'http://idmanagement.gov/ns/assurance/ial/2' - - maybe_update_user(user, data) - session[:login_dot_gov_token] = auth.credentials.token - session[:login_dot_gov_token_exp] = auth.credentials.expires_in.seconds.from_now - end - - def path(user, auth) - if user.blank? && auth.extra.raw_info.ial == 'http://idmanagement.gov/ns/assurance/ial/1' - Rails.logger.info(['User logged in without account', - { actionContext: LoggingConstants::ActionContext::Authentication, - actionType: LoggingConstants::ActionType::UserLoginWithoutAccount }]) - return no_account_url - end - session.delete(:user_return_to) || organizations_path - end - - def csp - csp = Csp.active.find_by(name: :login_dot_gov) - return csp if csp - - Rails.logger.info(['User attempted to login with Login.gov but no active CSP found', - { actionContext: LoggingConstants::ActionContext::Authentication, - actionType: LoggingConstants::ActionType::InvalidCsp }]) - render(Page::Utility::ErrorComponent.new(nil, 'login_gov_signin_fail')) - nil - end - - def post_signin_actions(user, csp_user, auth) - ial_2_actions(user, auth) - update_email(csp_user, auth.extra.raw_info.all_emails) + def ial_1_user?(auth) + auth.extra.raw_info.ial == 'http://idmanagement.gov/ns/assurance/ial/1' end end -# rubocop:enable Metrics/ClassLength diff --git a/dpc-portal/app/controllers/users/sessions_controller.rb b/dpc-portal/app/controllers/users/sessions_controller.rb index c82b22b26f..e38cd2186b 100644 --- a/dpc-portal/app/controllers/users/sessions_controller.rb +++ b/dpc-portal/app/controllers/users/sessions_controller.rb @@ -10,7 +10,11 @@ def destroy { actionContext: LoggingConstants::ActionContext::Authentication, actionType: LoggingConstants::ActionType::UserLoggedOut }]) session.delete('user') - redirect_to url_for_login_dot_gov_logout, allow_other_host: true + csp = session.delete(:csp) + session.delete("#{csp}_token") if csp + session.delete("#{csp}_token_exp") if csp + + redirect_to url_for_logout(csp), allow_other_host: true end def logged_out diff --git a/dpc-portal/app/jobs/verify_resource_health_job.rb b/dpc-portal/app/jobs/verify_resource_health_job.rb index 47b35a9bfe..4687613e21 100644 --- a/dpc-portal/app/jobs/verify_resource_health_job.rb +++ b/dpc-portal/app/jobs/verify_resource_health_job.rb @@ -9,7 +9,7 @@ class VerifyResourceHealthJob < ApplicationJob METRIC_NAMESPACE = 'DPC' REGION = 'us-east-1' ENVIRONMENT = ENV.fetch('ENV', 'none') - IDP_HOST = ENV.fetch('IDP_HOST', nil) + IDP_HOST = ENV.fetch('IDP_ID_ME_HOST', nil) # Runs all healthchecks if no args provided def perform(args = {}) diff --git a/dpc-portal/app/models/csp_config.rb b/dpc-portal/app/models/csp_config.rb new file mode 100644 index 0000000000..51b08a2c33 --- /dev/null +++ b/dpc-portal/app/models/csp_config.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'erb' +# Config class to hold CSP config as defined in a config file +class CspConfig + ENV_NAME = ENV.fetch('ENV', 'local') + CONFIG = Rails.application.config_for(:csp).freeze + + def initialize(code, host, identifier, user_info_endpoint, log_out_path, token_expiration_interval) # rubocop:disable Metrics/ParameterLists + @host = host + @identifier = identifier + @code = code + @user_info_endpoint = user_info_endpoint + @log_out_path = log_out_path + @token_expiration_interval = token_expiration_interval + end + + LOGIN_DOT_GOV = new('login_dot_gov', + CONFIG[:login_dot_gov][:host], + CONFIG[:login_dot_gov][:identifier], + CONFIG[:login_dot_gov][:user_info_path], + CONFIG[:login_dot_gov][:log_out_path], + CONFIG[:login_dot_gov][:token_expiration_interval]) + ID_ME = new('id_me', + CONFIG[:id_me][:host], + CONFIG[:id_me][:identifier], + CONFIG[:id_me][:user_info_path], + CONFIG[:id_me][:log_out_path], + CONFIG[:id_me][:token_expiration_interval]) + # CLEAR = new('clear', + # CONFIG[:clear][:host], + # CONFIG[:clear][:identifier], + # CONFIG[:clear][:user_info_path], + # CONFIG[:clear][:log_out_path], + # CONFIG[:clear][:token_expiration_interval]) + private_class_method :new + + attr_reader :user_info_endpoint, :log_out_path, :token_expiration_interval, :host, :identifier + + def self.for(code) + case code.to_s + when 'login_dot_gov' then LOGIN_DOT_GOV + when 'id_me' then ID_ME + # when 'clear' then CLEAR + else raise ArgumentError, "Unknown CSP code: #{code}" + end + end + + def self.[](code) + from(code) + end + + def self.list + [LOGIN_DOT_GOV.code, ID_ME.code] # CLEAR + end +end diff --git a/dpc-portal/app/models/csp_user.rb b/dpc-portal/app/models/csp_user.rb index d0e2c57cea..fc7d4d8bbe 100644 --- a/dpc-portal/app/models/csp_user.rb +++ b/dpc-portal/app/models/csp_user.rb @@ -5,4 +5,39 @@ class CspUser < ApplicationRecord belongs_to :user belongs_to :csp has_many :user_emails + + def add_or_activate_new_email(new_emails) + existing_emails = user_emails + new_emails&.uniq&.each do |new_email| + existing_email = existing_emails.find do |user_email| + user_email.email == new_email + end + + if existing_email.nil? + # Add this email + UserEmail.create!(csp_user: self, email: new_email, active: true) + else + # Potentially activate this email + activate_email(existing_email) + end + end + end + + def deactivate_old_email(new_emails) + # Don't deactivate existing emails if new_emails is empty + return if new_emails.nil? || new_emails.empty? + + # If an existing email is no longer in the list provided by the CSP, deactivate it. + user_emails&.each do |existing_email| + unless new_emails&.include?(existing_email.email) + existing_email.update!(active: false, deactivated_at: Time.current, reactivated_at: nil) + end + end + end + + def activate_email(user_email) + return unless user_email.active == false + + user_email.update!(active: true, deactivated_at: nil, reactivated_at: Time.current) + end end diff --git a/dpc-portal/app/models/invitation.rb b/dpc-portal/app/models/invitation.rb index 48f5910dd6..80d8094027 100644 --- a/dpc-portal/app/models/invitation.rb +++ b/dpc-portal/app/models/invitation.rb @@ -74,11 +74,11 @@ def renew end def ao_match?(user_info) - check_missing_user_info(user_info, 'social_security_number') - + check_missing_user_info(user_info, 'social_security_number', 'SSN', check_all_keys: false) + ssn = user_info['social_security_number']&.tr('-', '') || user_info['SSN'] service = AoVerificationService.new - result = service.check_eligibility(provider_organization.npi, - user_info['social_security_number'].tr('-', '')) + result = service.check_eligibility(provider_organization.npi, ssn) + raise VerificationError, result[:failure_reason] unless result[:success] result @@ -138,10 +138,12 @@ def cd_info_present?(user_info) end end - def check_missing_user_info(user_info, key) - return if user_info[key].present? + def check_missing_user_info(user_info, *keys, check_all_keys: true) + missing_keys = keys.reject { |key| user_info[key].present? } + return if missing_keys.empty? + return if !check_all_keys && missing_keys.size < keys.size - Rails.logger.error("User Info Missing: #{key}") + Rails.logger.error("User Info Missing: #{missing_keys}") raise UserInfoServiceError, 'missing_info' end diff --git a/dpc-portal/app/models/user.rb b/dpc-portal/app/models/user.rb index ced291c76b..df307b6d5b 100644 --- a/dpc-portal/app/models/user.rb +++ b/dpc-portal/app/models/user.rb @@ -9,12 +9,24 @@ class User < ApplicationRecord validates :verification_status, allow_nil: true, inclusion: { in: :verification_status } + has_many :csp_users + has_many :csps, through: :csp_users + # has_many :user_emails has_many :ao_org_links has_many :cd_org_links enum :verification_reason, %i[ao_med_sanction_waived ao_med_sanctions] enum :verification_status, %i[approved rejected] + def csp_user_for(name) + csp_users.joins(:csp).where(csps: { name: name }).first + end + + def self.find_by_csp_uid(name:, csp_uid:) + id_to_find = csp_uid + joins(csp_users: :csp).where(csp_users: { uuid: id_to_find }, csps: { name: name }).first + end + def self.remember_for 12.hours end diff --git a/dpc-portal/app/services/user_info_service.rb b/dpc-portal/app/services/user_info_service.rb index 1758f51433..c128d3b96e 100644 --- a/dpc-portal/app/services/user_info_service.rb +++ b/dpc-portal/app/services/user_info_service.rb @@ -2,12 +2,10 @@ # A service that verifies generates an ao invitation class UserInfoService - USER_INFO_URI = URI("https://#{ENV.fetch('IDP_HOST')}/api/openid_connect/userinfo") - def user_info(session) validate_session(session) - request_info(session[:login_dot_gov_token]) + request_info(session[:csp], session["#{session[:csp]}_token"]) end private @@ -17,14 +15,48 @@ def auth_header(token) end def validate_session(session) - raise UserInfoServiceError, 'no_token' unless session[:login_dot_gov_token].present? - raise UserInfoServiceError, 'no_token_exp' unless session[:login_dot_gov_token_exp].present? - raise UserInfoServiceError, 'expired_token' unless session[:login_dot_gov_token_exp] > Time.now + raise UserInfoServiceError, 'no_session' unless session[:csp].present? + + csp = session[:csp] + raise UserInfoServiceError, 'no_token' unless session["#{csp}_token"].present? + raise UserInfoServiceError, 'no_token_exp' unless session["#{csp}_token_exp"].present? + raise UserInfoServiceError, 'expired_token' unless session["#{csp}_token_exp"] > Time.now + end + + def oidc_client_config(csp) + return ID_ME_CLIENT_CONFIG if csp.to_s == :id_me.to_s + return LOGIN_DOT_GOV_CLIENT_CONFIG if csp.to_s == :login_dot_gov.to_s + + # TODO: Add CLEAR_CONFIG here + + raise UserInfoServiceError, 'invalid_csp' + end + + def parsed_response(response) + return if response.body.blank? + + body = response.body.to_s.strip + if response.content_type.to_s.strip.downcase == 'application/jwt' || looks_like_jwt?(body) + decode_jwt(body) + else + JSON.parse(body).with_indifferent_access + end + end + + def looks_like_jwt?(body) + parts = body.to_s.strip.split('.') + parts.length == 3 && parts.all? { |p| p.match?(/\A[A-Za-z0-9_-]+\z/) } + end + + def decode_jwt(body) + body = body[1..-2] if body.start_with?('"') && body.end_with?('"') + JSON::JWT.decode(body, :skip_verification).to_h.with_indifferent_access end - def request_info(token) - start_tracking - response = Net::HTTP.get_response(USER_INFO_URI, auth_header(token)) + def request_info(csp, token) # rubocop:disable Metrics/AbcSize + csp_config = oidc_client_config csp + start_tracking csp, csp_config[:client_options][:userinfo_endpoint] + response = Net::HTTP.get_response(URI(csp_config[:client_options][:userinfo_endpoint]), auth_header(token)) code = response.code.to_i case code when 200...299 @@ -40,36 +72,32 @@ def request_info(token) Rails.logger.error 'Could not connect to login.gov' raise UserInfoServiceError, 'server_error' ensure - finish_tracking(code) - end - - def parsed_response(response) - return if response.body.blank? - - JSON.parse response.body + finish_tracking(code, csp, csp_config[:client_options][:userinfo_endpoint]) end - def start_tracking + def start_tracking(csp, user_info_uri) @start = Time.now Rails.logger.info( - ['Calling Login.gov user_info', - { login_dot_gov_request_method: :get, - login_dot_gov_request_url: USER_INFO_URI, - login_dot_gov_request_method_name: :request_info }] + ['Calling CSP user_info', + { csp: csp, + csp_request_method: :get, + csp_request_url: user_info_uri, + csp_request_method_name: :request_info }] ) - @tracker = NewRelic::Agent::Tracer.start_external_request_segment(library: 'Net::HTTP', uri: USER_INFO_URI, + @tracker = NewRelic::Agent::Tracer.start_external_request_segment(library: 'Net::HTTP', uri: user_info_uri, procedure: :get) end - def finish_tracking(code) + def finish_tracking(code, csp, user_info_uri) @tracker.finish Rails.logger.info( - ['Login.gov user_info response info', - { login_dot_gov_request_method: :get, - login_dot_gov_request_url: USER_INFO_URI, - login_dot_gov_request_method_name: :request_info, - login_dot_gov_response_status_code: code, - login_dot_gov_response_duration: Time.now - @start }] + ['CSP user_info response info', + { csp: csp, + csp_request_method: :get, + csp_request_url: user_info_uri, + csp_request_method_name: :request_info, + csp_response_status_code: code, + csp_response_duration: Time.now - @start }] ) end end diff --git a/dpc-portal/app/views/users/sessions/new.html.erb b/dpc-portal/app/views/users/sessions/new.html.erb index 3949df9114..2d6b74a840 100644 --- a/dpc-portal/app/views/users/sessions/new.html.erb +++ b/dpc-portal/app/views/users/sessions/new.html.erb @@ -1 +1 @@ -<%= render(Page::Session::LoginComponent.new(omniauth_authorize_path(:login_dot_gov))) %> +<%= render(Page::Session::LoginComponent.new(omniauth_authorize_path(session[:csp]))) %> diff --git a/dpc-portal/config/csp.yml b/dpc-portal/config/csp.yml new file mode 100644 index 0000000000..89d5807b8a --- /dev/null +++ b/dpc-portal/config/csp.yml @@ -0,0 +1,35 @@ +shared: + port: 443 + scheme: 'https' +development: &development + login_dot_gov: + host: <%= ENV['IDP_LOGIN_DOT_GOV_HOST'] %> + identifier: '<%= "urn:gov:cms:openidconnect.profiles:sp:sso:cms:dpc:#{ENV['ENV']}" %>' + user_info_endpoint: <%= "https://#{ENV['IDP_ID_ME_HOST']}/api/openid_connect/userinfo" %> + log_out_path: '/openid_connect/logout' + token_expiration_interval: 300 + redirect_path: '/auth/login_dot_gov/callback' + authorization_endpoint: <%= "https://#{ENV['IDP_LOGIN_DOT_GOV_HOST']}/openid_connect/authorize" %> + token_endpoint: <%= "https://#{ENV['IDP_LOGIN_DOT_GOV_HOST']}/api/openid_connect/token" %> + jwks_uri: <%= "https://#{ENV['IDP_LOGIN_DOT_GOV_HOST']}/api/openid_connect/certs" %> + + id_me: + host: <%= ENV['IDP_ID_ME_HOST'] %> + identifier: <%= ENV['IDP_ID_ME_CLIENT_ID'] %> + client_secret: <%= ENV['IDP_ID_ME_CLIENT_SECRET'] %> + authorization_endpoint: <%= "https://#{ENV['IDP_ID_ME_HOST']}/oauth/authorize" %> + token_endpoint: <%= "https://#{ENV['IDP_ID_ME_HOST']}/oauth/token" %> + user_info_endpoint: <%= "https://#{ENV['IDP_ID_ME_HOST']}/api/public/v3/userinfo" %> + jwks_uri: <%= "https://#{ENV['IDP_ID_ME_HOST']}/oidc/.well-known/jwks" %> + redirect_path: '/auth/id_me/callback' + log_out_path: '/oauth/logout' + token_expiration_interval: 300 + +local: + <<: *development + +test: + <<: *development + +production: + <<: *development diff --git a/dpc-portal/config/environments/test.rb b/dpc-portal/config/environments/test.rb index fa685743a9..0ff14438d6 100644 --- a/dpc-portal/config/environments/test.rb +++ b/dpc-portal/config/environments/test.rb @@ -70,4 +70,5 @@ end ENV['CPI_API_GW_BASE_URL'] = 'https://val.cpiapi.cms.gov/' ENV['CMS_IDM_OAUTH_URL'] = 'https://impl.idp.idm.cms.gov/' -ENV['IDP_HOST'] = 'idp.int.identitysandbox.gov' +ENV['IDP_LOGIN_DOT_GOV_HOST'] = 'idp.int.identitysandbox.gov' +ENV['IDP_ID_ME_HOST'] = 'api.idmelabs.com' diff --git a/dpc-portal/config/initializers/omniauth.rb b/dpc-portal/config/initializers/omniauth.rb index c2a3d3fb8c..426d3636e6 100644 --- a/dpc-portal/config/initializers/omniauth.rb +++ b/dpc-portal/config/initializers/omniauth.rb @@ -6,30 +6,61 @@ include DpcPortalUtils +PORTAL_CSP_CONFIG = Rails.application.config_for(:csp).freeze +ID_ME_CONFIG = PORTAL_CSP_CONFIG[:id_me].freeze +LOGIN_DOT_GOV_CONFIG = PORTAL_CSP_CONFIG[:login_dot_gov].freeze + +ID_ME_CLIENT_CONFIG = { + name: :id_me, + issuer: "https://#{ID_ME_CONFIG[:host]}/oidc", + scope: %i[openid http://idmanagement.gov/ns/assurance/ial/2/aal/2], + response_type: :code, + client_auth_method: :client_secret_post, + client_options: { + port: 443, + scheme: 'https', + host: ID_ME_CONFIG[:host], + identifier: ID_ME_CONFIG[:identifier], + secret: ID_ME_CONFIG[:client_secret], + redirect_uri: "#{my_protocol_host}#{ID_ME_CONFIG[:redirect_path]}", + authorization_endpoint: ID_ME_CONFIG[:authorization_endpoint], + token_endpoint: ID_ME_CONFIG[:token_endpoint], + userinfo_endpoint: ID_ME_CONFIG[:user_info_endpoint], + jwks_uri: ID_ME_CONFIG[:jwks_uri], + userinfo_signed_response_alg: 'RS256', + id_token_signed_response_alg: 'RS256' + } +}.freeze + +LOGIN_DOT_GOV_CLIENT_CONFIG = { + name: :login_dot_gov, + issuer: "https://#{LOGIN_DOT_GOV_CONFIG[:host]}/", + discovery: false, + scope: %i[openid email all_emails], + response_type: :code, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + client_auth_method: :jwt_bearer, + client_options: { + port: 443, + scheme: 'https', + host: "https://#{LOGIN_DOT_GOV_CONFIG[:host]}/", + identifier: "urn:gov:cms:openidconnect.profiles:sp:sso:cms:dpc:#{ENV['ENV']}", + private_key: ENV['LOGIN_DOT_GOV_CLIENT_PRIVATE_KEY'], + redirect_uri: "#{my_protocol_host}/portal/auth/login_dot_gov/callback", + + authorization_endpoint: LOGIN_DOT_GOV_CONFIG[:authorization_endpoint], + token_endpoint: LOGIN_DOT_GOV_CONFIG[:token_endpoint], + userinfo_endpoint: LOGIN_DOT_GOV_CONFIG[:user_info_endpoint], + jwks_uri: LOGIN_DOT_GOV_CONFIG[:jwks_uri], + } +} + Rails.application.config.middleware.use OmniAuth::Builder do OmniAuth.config.logger = Rails.logger - begin - private_key = OpenSSL::PKey::RSA.new(ENV['LOGIN_GOV_PRIVATE_KEY']) - rescue TypeError, OpenSSL::PKey::RSAError => e - Rails.logger.error("Unable to create private key for omniauth: #{e}") - private_key = OpenSSL::PKey::RSA.new(1024) - end - idp_host = ENV.fetch('IDP_HOST', 'idp.int.identitysandbox.gov') - provider :openid_connect, { - name: :login_dot_gov, - issuer: "https://#{idp_host}/", - discovery: true, - scope: %i[openid email all_emails], - response_type: :code, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', - client_auth_method: :jwt_bearer, - client_options: { - port: 443, - scheme: 'https', - host: idp_host, - identifier: "urn:gov:cms:openidconnect.profiles:sp:sso:cms:dpc:#{ENV['ENV']}", - private_key: private_key, - redirect_uri: "#{my_protocol_host}/auth/login_dot_gov/callback" - } - } + + ## ID.me + provider :openid_connect, ID_ME_CLIENT_CONFIG + + ## Login.gov + provider :openid_connect, LOGIN_DOT_GOV_CLIENT_CONFIG end diff --git a/dpc-portal/config/initializers/omniauth_openid_connect_patch.rb b/dpc-portal/config/initializers/omniauth_openid_connect_patch.rb new file mode 100644 index 0000000000..59760cf068 --- /dev/null +++ b/dpc-portal/config/initializers/omniauth_openid_connect_patch.rb @@ -0,0 +1,76 @@ +require 'json/jwt' +require 'openid_connect' + +module OmniAuth + module Strategies + class OpenIDConnect + def user_info + @user_info ||= ::OpenIDConnect::ResponseObject::UserInfo.new(fetch_userinfo_payload) + rescue => e + Rails.logger.error "[OIDC Patch Error] #{e.class}: #{e.message}" + fail!(:user_info_failed, e) + nil + end + + private + + # Calls the userinfo endpoint with the bearer access token and returns + # the claims as a Hash. If the IdP responds with a signed JWT + # (application/jwt), the JWT is decoded without signature verification + # and the payload is returned. Otherwise the JSON body is parsed. + # Fetches and parses the userinfo payload from the OpenID Connect provider. + # + # This method retrieves user information from the userinfo endpoint using the access token, + # handles various response formats (JSON, JWT, JSON-encoded JWT), and returns the parsed payload. + # + # The method handles several IdP variations: + # - Some providers return raw JSON + # - Some providers return a JWT (JSON Web Token) + # - Some providers JSON-encode the JWT, wrapping it as a string: `""` + # + # @return [Hash] A hash with indifferent access containing the userinfo payload. + # If the response is a JWT, it is decoded and converted to a hash. + # If the response is JSON, it is parsed and converted to a hash. + # Keys can be accessed with symbols or strings. + # + # @note JSON::JWT.decode returns a JWT object that responds to #to_h, converting it to a Hash + def fetch_userinfo_payload + response = ::OpenIDConnect.http_client.get( + userinfo_endpoint_uri, + nil, + { 'Authorization' => "Bearer #{access_token.access_token}" } + ) + body = response.body.to_s.strip + ct_header = Array(response.headers['Content-Type']).first.to_s + content_type = ct_header.split(';').first.to_s.strip.downcase + + if content_type == 'application/jwt' || looks_like_jwt?(body) + body = body[1..-2] if body.start_with?('"') && body.end_with?('"') + ## TODO - consider verifying the JWT signature using the provider's JWKS keys + JSON::JWT.decode(body, :skip_verification).to_h.with_indifferent_access + else + JSON.parse(body).with_indifferent_access + end + end + + def userinfo_endpoint_uri + endpoint = client_options.userinfo_endpoint + parsed = URI.parse(endpoint) + return parsed.to_s if parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS) + + host_with_port = + if client_options.port && ![80, 443].include?(client_options.port) + "#{client_options.host}:#{client_options.port}" + else + client_options.host + end + "#{client_options.scheme}://#{host_with_port}#{endpoint}" + end + + def looks_like_jwt?(body) + parts = body.to_s.strip.split('.') + parts.length == 3 && parts.all? { |p| p.match?(/\A[A-Za-z0-9_-]+\z/) } + end + end + end +end diff --git a/dpc-portal/config/routes.rb b/dpc-portal/config/routes.rb index 996dc19bc0..6df67774f6 100644 --- a/dpc-portal/config/routes.rb +++ b/dpc-portal/config/routes.rb @@ -6,15 +6,16 @@ # Rails.application.routes.draw do # Former devise routes - get '/users/auth/failure', to: 'login_dot_gov#failure', as: 'login_dot_gov_failure' + get '/users/auth/failure', to: 'csp#failure', as: 'csp_failure' get '/auth/logged_out', to: 'users/sessions#logged_out' - get '/auth/no_account', to: 'login_dot_gov#no_account', as: 'no_account' - delete '/logout', to: 'login_dot_gov#logout', as: 'login_dot_gov_logout' + get '/auth/no_account', to: 'csp#no_account', as: 'no_account' + delete '/logout', to: 'csp#logout', as: 'csp_logout' get 'active', to: 'users/sessions#active', as: 'active' get 'timeout', to: 'users/sessions#timeout', as: 'timeout' get '/users/sign_in', to: 'users/sessions#new', as: 'sign_in' delete '/users/sign_out', to: 'users/sessions#destroy', as: 'destroy_user_session' get '/auth/login_dot_gov/callback', to: 'login_dot_gov#openid_connect' + get '/auth/id_me/callback', to: 'id_me#openid_connect' # Defines the root path route ("/") root 'organizations#index' diff --git a/dpc-portal/db/migrate/20260519184116_change_csp_name_to_id_me.rb b/dpc-portal/db/migrate/20260519184116_change_csp_name_to_id_me.rb new file mode 100644 index 0000000000..9db1e6c0cb --- /dev/null +++ b/dpc-portal/db/migrate/20260519184116_change_csp_name_to_id_me.rb @@ -0,0 +1,6 @@ +class ChangeCspNameToIdMe < ActiveRecord::Migration[8.0] + def change + csp = Csp.find_by(name: :id_dot_me) + csp.update(name: :id_me) + end +end diff --git a/dpc-portal/db/migrate/20260528170136_change_uuid_column_type_in_csp_users.rb b/dpc-portal/db/migrate/20260528170136_change_uuid_column_type_in_csp_users.rb new file mode 100644 index 0000000000..97c244361b --- /dev/null +++ b/dpc-portal/db/migrate/20260528170136_change_uuid_column_type_in_csp_users.rb @@ -0,0 +1,5 @@ +class ChangeUuidColumnTypeInCspUsers < ActiveRecord::Migration[8.0] + def change + change_column(:csp_users, :uuid, :string) + end +end diff --git a/dpc-portal/db/schema.rb b/dpc-portal/db/schema.rb index 543bcd7295..26878e3b1e 100644 --- a/dpc-portal/db/schema.rb +++ b/dpc-portal/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_24_194005) do +ActiveRecord::Schema[8.0].define(version: 2026_05_28_170136) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -72,7 +72,7 @@ create_table "csp_users", force: :cascade do |t| t.bigint "user_id", null: false t.bigint "csp_id", null: false - t.uuid "uuid" + t.string "uuid" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["csp_id"], name: "index_csp_users_on_csp_id" diff --git a/dpc-portal/docker/entrypoint.sh b/dpc-portal/docker/entrypoint.sh index 055a21b77f..b00314eee6 100755 --- a/dpc-portal/docker/entrypoint.sh +++ b/dpc-portal/docker/entrypoint.sh @@ -16,7 +16,7 @@ if [ "$1" == "portal" ]; then echo "Starting Rails server..." echo "Migrating the database..." - bundle exec rails db:migrate + bundle exec rails db:migrate db:seed if [[ "$ENV" == "production" ]]; then echo "Starting in production" diff --git a/dpc-portal/spec/fixtures/csps/login_dot_gov/user_info.json b/dpc-portal/spec/fixtures/csps/login_dot_gov/user_info.json new file mode 100644 index 0000000000..ca46d9b710 --- /dev/null +++ b/dpc-portal/spec/fixtures/csps/login_dot_gov/user_info.json @@ -0,0 +1,19 @@ +{ + "sub": "097d06f7-e9ad-4327-8db3-0ba193b7a2c2", + "iss": "https://api.idmelabs.com/oidc", + "email": "david@example.com", + "email_verified": true, + "all_emails": [ + "david@example.com", + "david2@example.com" + ], + "given_name": "David", + "family_name": "Davis", + "birthdate": "1938-10-06", + "social_security_number": "900888888", + "phone": "+19174216435", + "phone_verified": true, + "verified_at": 1704834157, + "ial": "http://idmanagement.gov/ns/assurance/ial/2", + "aal": "urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo" +} \ No newline at end of file diff --git a/dpc-portal/spec/jobs/verify_resource_health_job_spec.rb b/dpc-portal/spec/jobs/verify_resource_health_job_spec.rb index 80139da3ae..f3d97a1bd0 100644 --- a/dpc-portal/spec/jobs/verify_resource_health_job_spec.rb +++ b/dpc-portal/spec/jobs/verify_resource_health_job_spec.rb @@ -86,6 +86,7 @@ context 'not connected to AWS' do it 'should ignore connection error and move on gracefully' do stub_request(:get, 'https://idp.int.identitysandbox.gov').to_return(status: 200) + stub_request(:get, 'https://api.idmelabs.com').to_return(status: 200) expect(mock_dpc_client).to receive(:healthcheck) expect(mock_dpc_client).to receive(:response_successful?).and_return(true).twice @@ -150,6 +151,7 @@ def expect_cpi(auth_health: true, api_health: true, metric: 1) def expect_idp(site_status: 200, metric: 1) stub_request(:get, 'https://idp.int.identitysandbox.gov').to_return(status: site_status) + stub_request(:get, 'https://api.idmelabs.com').to_return(status: site_status) expect_put_metric('PortalConnectedToIdp', metric) end diff --git a/dpc-portal/spec/rails_helper.rb b/dpc-portal/spec/rails_helper.rb index 4796e09ff7..47dff37f69 100644 --- a/dpc-portal/spec/rails_helper.rb +++ b/dpc-portal/spec/rails_helper.rb @@ -11,6 +11,7 @@ require 'support/component_support' require 'support/dpc_client_support' require 'support/match_html_fragment' +require 'support/fixture_helper' # Add additional requires below this line. Rails is not loaded until this point! require 'view_component/test_helpers' # Requires supporting ruby files with custom matchers and macros, etc, in @@ -37,6 +38,7 @@ end RSpec.configure do |config| config.include FactoryBot::Syntax::Methods + config.include FixtureHelper # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_paths = ["#{Rails.root}/spec/fixtures"] diff --git a/dpc-portal/spec/requests/application_spec.rb b/dpc-portal/spec/requests/application_spec.rb index e5a83c3537..f4a66e720e 100644 --- a/dpc-portal/spec/requests/application_spec.rb +++ b/dpc-portal/spec/requests/application_spec.rb @@ -6,8 +6,8 @@ RSpec.describe 'Application', type: :request do include LoginSupport - let!(:user) { create(:user) } - before { sign_in user } + let!(:user) { create_user_with_csp } + before { sign_in(user:, csp: :login_dot_gov) } it 'sets cache control to no-store' do get '/' diff --git a/dpc-portal/spec/requests/client_tokens_spec.rb b/dpc-portal/spec/requests/client_tokens_spec.rb index 0109ad44ab..8abff69758 100644 --- a/dpc-portal/spec/requests/client_tokens_spec.rb +++ b/dpc-portal/spec/requests/client_tokens_spec.rb @@ -25,9 +25,9 @@ context 'ao access denied' do context 'user has sanctions' do - let!(:user) { create(:user, verification_status: 'rejected', verification_reason: 'ao_med_sanctions') } + let!(:user) { create_user_with_csp(verification_status: 'rejected', verification_reason: 'ao_med_sanctions') } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -38,12 +38,12 @@ end context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -53,12 +53,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -68,9 +68,9 @@ end context 'user no longer ao' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:, verification_status: false, @@ -82,12 +82,12 @@ end context 'cd access denied' do context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:cd_org_link, provider_organization: org, user:) @@ -97,12 +97,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:cd_org_link, provider_organization: org, user:) @@ -113,9 +113,9 @@ end context 'no link to org' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'redirects to organizations' do get "/organizations/#{org.id}/client_tokens/new" expect(response).to redirect_to('/organizations') @@ -123,12 +123,12 @@ end context :not_signed_tos do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'redirects to organizations page' do @@ -139,12 +139,12 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'returns success' do @@ -164,13 +164,13 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let(:org_api_id) { SecureRandom.uuid } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:, dpc_api_organization_id: org_api_id) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'succeeds if label' do @@ -229,13 +229,13 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let(:org_api_id) { SecureRandom.uuid } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:, dpc_api_organization_id: org_api_id) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'flashes success if succeeds' do diff --git a/dpc-portal/spec/requests/credential_delegate_invitations_spec.rb b/dpc-portal/spec/requests/credential_delegate_invitations_spec.rb index 948116a42e..5e49932342 100644 --- a/dpc-portal/spec/requests/credential_delegate_invitations_spec.rb +++ b/dpc-portal/spec/requests/credential_delegate_invitations_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' RSpec.describe 'CredentialDelegateInvitations', type: :request do include DpcClientSupport @@ -15,12 +16,12 @@ end context 'as ao' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } before do create(:ao_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'returns success' do @@ -44,9 +45,13 @@ end context 'user has sanctions' do - let!(:user) { create(:user, verification_status: 'rejected', verification_reason: 'ao_med_sanctions') } + let!(:user) do + create_user_with_csp(given_name: 'John', family_name: 'Smith', csp: :login_dot_gov, + verification_status: 'rejected', + verification_reason: 'ao_med_sanctions') + end let!(:org) { create(:provider_organization) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -57,12 +62,12 @@ end context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by: user, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -72,12 +77,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by: user, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -87,9 +92,9 @@ end context 'user no longer ao' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by: user) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:, verification_status: false, @@ -100,11 +105,11 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'redirects to organizations' do get "/organizations/#{org.id}/credential_delegate_invitations/new" @@ -114,7 +119,7 @@ end describe 'POST /create' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by: user) } let!(:successful_parameters) do { invited_given_name: 'Bob', @@ -127,7 +132,7 @@ let(:api_id) { org.id } before do create(:ao_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'creates invitation record on success' do @@ -197,7 +202,7 @@ context 'as cd' do before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'fails even with good parameters' do @@ -210,14 +215,14 @@ end describe 'Delete /destroy' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by: user) } let!(:invitation) { create(:invitation, :cd, provider_organization: org) } context 'as cd' do before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'fails' do delete "/organizations/#{org.id}/credential_delegate_invitations/#{invitation.id}" @@ -229,7 +234,7 @@ context 'as ao' do before do create(:ao_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'soft deletes invitation' do expect do diff --git a/dpc-portal/spec/requests/id_me_spec.rb b/dpc-portal/spec/requests/id_me_spec.rb new file mode 100644 index 0000000000..0b449d66e8 --- /dev/null +++ b/dpc-portal/spec/requests/id_me_spec.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'securerandom' + +RSpec.describe 'IdMe', type: :request do + let(:uuid) { SecureRandom.uuid } + describe 'POST /auth/id_me' do + let!(:csp) { create(:csp, :id_me) } + RSpec.shared_examples 'an id.me client' do + context 'user exists' do + before do + user = create(:user, email: 'bob1@example.com', provider: :id_me) + create(:csp_user, user:, uuid:, csp:) + end + it 'should sign in a user' do + post '/auth/id_me' + follow_redirect! + expect(response.location).to eq organizations_url + expect(response).to be_redirect + follow_redirect! + expect(response).to be_ok + end + it 'should log on successful sign in' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(['User logged in', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::UserLoggedIn }]) + post '/auth/id_me' + follow_redirect! + end + it 'should not add another user' do + expect(CspUser.where(uuid:, csp:).count).to eq 1 + expect do + post '/auth/id_me' + follow_redirect! + end.to change { CspUser.count }.by(0) + end + end + + context 'user does not exist' do + it 'should not persist user' do + expect do + post '/auth/id_me' + follow_redirect! + end.to change { User.count }.by(0) + end + end + end + + let(:token) { 'bearer-token' } + context 'IAL/2' do + before do + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:id_me, + { uid: uuid, + credentials: { expires_in: 899, + token: }, + info: { email: 'bob2@example.com' }, + extra: { raw_info: { given_name: 'Bob', + family_name: 'Hoskins', + social_security_number: '1-2-3', + identity_assurance_level: 2 } } }) + end + + it_behaves_like 'an id.me client' + + context :user_exists do + let(:db_user) { create(:user, uid: '12345', provider: 'id_me', email: 'bob@example.com') } + before do + create(:csp_user, user: db_user, uuid:, csp:) + end + it 'updates user names' do + expect do + post '/auth/id_me' + follow_redirect! + end.to change { + User.where(id: db_user.id, given_name: 'Bob', + family_name: 'Hoskins').count + }.by 1 + expect(response.location).to eq organizations_url + end + + it 'sets authentication token' do + post '/auth/id_me' + follow_redirect! + expect(request.session[:id_me_token]).to eq token + expect(request.session[:id_me_token_exp]).to_not be_nil + expect(request.session[:id_me_token_exp]).to be_within(1.second).of 899.seconds.from_now + end + end + + context :user_does_not_exist do + it 'does not sign in user' do + post '/auth/id_me' + follow_redirect! + expect(response.location).to eq organizations_url + expect(response).to be_redirect + follow_redirect! + expect(response).to be_redirect + end + + it 'sets authentication token' do + post '/auth/id_me' + follow_redirect! + expect(request.session[:id_me_token]).to eq token + expect(request.session[:id_me_token_exp]).to_not be_nil + expect(request.session[:id_me_token_exp]).to be_within(1.second).of 899.seconds.from_now + end + end + end + + context 'IAL/1' do + before do + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:id_me, + { uid: uuid, + info: { email: 'bob@example.com' }, + extra: { raw_info: { identity_assurance_level: 1 } } }) + end + + it_behaves_like 'an id.me client' + + context :user_exists do + before do + create(:user, provider: 'id_me', given_name: 'Bob', family_name: 'Hoskins') + create(:csp_user, user: User.last, uuid:, csp:) + end + it 'does not update user names' do + expect(CspUser.where(uuid:).count).to eq 1 + # expect(User.where(uid: '12345', provider: 'id_me', email: 'bob@example.com', given_name: 'Bob', + # family_name: 'Hoskins').count).to eq 1 + post '/auth/id_me' + follow_redirect! + expect(response.location).to eq organizations_url + expect(CspUser.where(uuid:, csp: csp).count).to eq 1 + db_user = CspUser.find_by(uuid:, csp: csp)&.user + expect(db_user).to be_present + expect(db_user.given_name).to eq 'Bob' + expect(db_user.family_name).to eq 'Hoskins' + # expect(User.where(uid: '12345', provider: 'id_me', email: 'bob@example.com', given_name: 'Bob', + # family_name: 'Hoskins').count).to eq 1 + end + + it 'does not set authentication token' do + post '/auth/id_me' + follow_redirect! + expect(request.session[:id_me_token]).to be_nil + expect(request.session[:id_me_token_exp]).to be_nil + end + end + + context 'user does not exist' do + it 'does not sign in user' do + post '/auth/id_me' + follow_redirect! + expect(response.location).to eq no_account_url + expect(response).to be_redirect + end + + it 'should log' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with( + ['User logged in without account', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::UserLoginWithoutAccount }] + ) + post '/auth/id_me' + follow_redirect! + end + + it 'does not set authentication token' do + post '/auth/id_me' + follow_redirect! + expect(request.session[:id_me_token]).to be_nil + expect(request.session[:id_me_token_exp]).to be_nil + end + end + end + + context 'should add email' do + before do + uuid = SecureRandom.uuid + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:id_me, + { uid: uuid, + credentials: { expires_in: 899, + token: }, + info: { email: 'email1@example.com' }, + extra: { raw_info: { given_name: 'Bob', + family_name: 'Hoskins', + social_security_number: '1-2-3', + identity_assurance_level: 2 } } }) + + user = create(:user, provider: :id_me) + create(:csp_user, user:, uuid:, csp:) + end + + it 'adds emails' do + expect do + post '/auth/id_me' + follow_redirect! + end.to change { UserEmail.count }.by(1) + + last_email = UserEmail.last(1) + expect(last_email.pluck(:email)).to match_array(%w[email1@example.com]) + expect(last_email.pluck(:active)).to all(be true) + end + end + + context 'should deactivate emails' do + before do + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:id_me, + { uid: uuid, + credentials: { expires_in: 899, + token: }, + info: { email: 'email1@example.com' }, + extra: { raw_info: { given_name: 'Bob', + family_name: 'Hoskins', + social_security_number: '1-2-3', + identity_assurance_level: 2 } } }) + + user = create(:user, email: 'email1@example.com', provider: :id_me) + csp_user = create(:csp_user, user:, uuid:, csp:) + create(:user_email, csp_user:, email: 'email@example.com', active: true) + end + + it 'deactivates email' do + post '/auth/id_me' + follow_redirect! + + email = UserEmail.find_by(csp_user: CspUser.last, email: 'email@example.com') + expect(email.active).to eq false + expect(email.deactivated_at).to_not be_nil + expect(email.reactivated_at).to be_nil + end + end + + context 'should reactivate emails' do + before do + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:id_me, + { uid: uuid, + credentials: { expires_in: 899, + token: }, + info: { email: 'email1@example.com' }, + extra: { raw_info: { given_name: 'Bob', + family_name: 'Hoskins', + social_security_number: '1-2-3', + identity_assurance_level: 2 } } }) + + user = create(:user, email: 'email1@example.com', provider: :id_me) + csp_user = create(:csp_user, user:, uuid:, csp:) + create(:user_email, csp_user:, email: 'email1@example.com', active: false, deactivated_at: 1.day.ago, + reactivated_at: nil) + end + + it 'reactivates emails' do + post '/auth/id_me' + follow_redirect! + + email = UserEmail.find_by(csp_user: CspUser.last, email: 'email1@example.com') + expect(email.active).to eq true + expect(email.deactivated_at).to be_nil + expect(email.reactivated_at).to_not be_nil + end + end + end + + describe 'Get /auth/failure' do + it 'should succeed' do + get '/users/auth/failure' + expect(response).to be_ok + end + + it 'should log on failure' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with(['User cancelled login', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::UserCancelledLogin }]) + get '/users/auth/failure' + end + end + + describe 'Delete /logout' do + before do + uuid = SecureRandom.uuid + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:id_me, + { uid: uuid, + credentials: { expires_in: 899, + token: 'bearer-token' }, + info: { email: 'email1@example.com' }, + extra: { raw_info: { given_name: 'Bob', + family_name: 'Hoskins', + social_security_number: '1-2-3', + identity_assurance_level: 2 } } }) + + user = create(:user, provider: :id_me) + csp = create(:csp, :id_me) + create(:csp_user, user:, uuid:, csp:) + post '/auth/id_me' + follow_redirect! + end + it 'should redirect to ID.me' do + delete '/logout' + expect(response.location).to include(ENV.fetch('IDP_ID_ME_HOST')) + expect(request.session[:user_return_to]).to be_nil + end + it 'should set return to invitation flow if invitation sent' do + invitation = create(:invitation, :ao) + delete "/logout?invitation_id=#{invitation.id}" + expect(request.session[:user_return_to]).to eq organization_invitation_url(invitation.provider_organization.id, + invitation.id) + end + end + + describe 'Get /auth/no_account' do + it 'should show logout button' do + get '/auth/no_account' + expect(response.body).to include 'Sign out of CSP' + end + end + + describe 'CSP inactive' do + before do + inactive_csp = create(:csp, :id_me, :inactive) + user = create(:user, email: 'bob5@example.com', provider: :id_me) + create(:csp_user, user:, uuid:, csp: inactive_csp) + + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:id_me, + { uid: uuid, + info: { email: 'bob4@example.com' }, + extra: { raw_info: { identity_assurance_level: 1 } } }) + end + + it 'should log error' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with( + ['User attempted to login but no active CSP found', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::InvalidCsp }] + ) + post '/auth/id_me' + follow_redirect! + end + end +end diff --git a/dpc-portal/spec/requests/invitations_spec.rb b/dpc-portal/spec/requests/invitations_spec.rb index 26689de18d..75f95e117c 100644 --- a/dpc-portal/spec/requests/invitations_spec.rb +++ b/dpc-portal/spec/requests/invitations_spec.rb @@ -14,6 +14,7 @@ let(:org) { invitation.provider_organization } let(:bad_org) { create(:provider_organization) } let(:expected_success_status) { 200 } + before { log_in } it 'should be ok or redirect' do send(method, "/organizations/#{org.id}/invitations/#{invitation.id}/#{path_suffix}") expect(response.status).to eq(expected_success_status) @@ -145,7 +146,6 @@ org_id = invitation.provider_organization.id post "/organizations/#{org_id}/invitations/#{invitation.id}/login" redirect_params = Rack::Utils.parse_query(URI.parse(response.location).query) - expect(redirect_params['acr_values']).to eq('http://idmanagement.gov/ns/assurance/ial/2') expect(redirect_params['redirect_uri']).to start_with('http://localhost:3100/auth/') expect(request.session[:user_return_to]).to eq expected_redirect end @@ -621,7 +621,10 @@ expect(user.family_name).to eq user_info_template['family_name'] expect(user.email).to eq user_info_template['email'] expect(user.uid).to eq user_info_template['sub'] - expect(user.provider).to eq 'login_dot_gov' + expect(user.csp_user_for('login_dot_gov')).to be_present + expect(user.csp_user_for('login_dot_gov').user_emails.map(&:email)).not_to be_empty + expect(user.csp_user_for('login_dot_gov') + .user_emails.map(&:email)).to include(*user_info_template['all_emails']) end it 'should log when user is created' do @@ -750,11 +753,11 @@ expect(request.session[:user_pac_id]).to be_nil end it 'should set pac_id on existing user' do - create(:user, email: user_info_template['email'], provider:) + create_invitation_user_with_csp(csp: provider) expect do post "/organizations/#{org.id}/invitations/#{invitation.id}/register" end.to change { User.count }.by 0 - user = User.find_by(email: user_info_template['email']) + user = User.find_by_csp_uid(name: provider, csp_uid: user_info_template['sub']) # We have the fake CPI API Gateway return the ssn as pac_id expect(user.pac_id).to eq user_info_template['social_security_number'] expect(request.session[:user_pac_id]).to be_nil @@ -875,24 +878,24 @@ end end -def log_in +def log_in(template: user_info_template, provider: 'login_dot_gov') OmniAuth.config.test_mode = true - OmniAuth.config.add_mock(:login_dot_gov, - { uid: '12345', + OmniAuth.config.add_mock(provider.to_sym, + { uid: template['sub'], credentials: { expires_in: 899, token: 'bearer-token' }, - info: { email: 'bob@example.com' }, - extra: { raw_info: { given_name: 'Bob', - family_name: 'Hoskins', + info: { email: template['email'] }, + extra: { raw_info: { given_name: template['given_name'], + family_name: template['family_name'], ial: 'http://idmanagement.gov/ns/assurance/ial/2' } } }) - post '/auth/login_dot_gov' + post "/auth/#{provider}" follow_redirect! end def user_info_template(overrides = {}) { 'sub' => '097d06f7-e9ad-4327-8db3-0ba193b7a2c2', - 'iss' => 'https://idp.int.identitysandbox.gov/', + 'iss' => 'https://api.idmelabs.com/oidc', 'email' => 'bob@testy.com', 'email_verified' => true, 'all_emails' => [ @@ -917,3 +920,10 @@ def stub_user_info(overrides: {}) expect(user_service).to receive(:user_info).at_least(:once).and_return(user_info_template(overrides)) end + +def create_invitation_user_with_csp(csp:) + template = user_info_template + create_user_with_csp(given_name: template['given_name'], family_name: template['family_name'], + email: template['email'], + csp:, uuid: template['sub']) +end diff --git a/dpc-portal/spec/requests/ip_addresses_spec.rb b/dpc-portal/spec/requests/ip_addresses_spec.rb index 4693c39fc3..652c897052 100644 --- a/dpc-portal/spec/requests/ip_addresses_spec.rb +++ b/dpc-portal/spec/requests/ip_addresses_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' require 'support/credential_resource_shared_examples' +require 'support/login_support' RSpec.describe 'IpAddresses', type: :request do include DpcClientSupport @@ -24,9 +25,9 @@ context 'ao access denied' do context 'user has sanctions' do - let!(:user) { create(:user, verification_status: 'rejected', verification_reason: 'ao_med_sanctions') } + let!(:user) { create_user_with_csp(verification_status: 'rejected', verification_reason: 'ao_med_sanctions') } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -37,12 +38,12 @@ end context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -52,12 +53,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -67,9 +68,9 @@ end context 'user no longer ao' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:, verification_status: false, @@ -81,12 +82,12 @@ end context 'cd access denied' do context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:cd_org_link, provider_organization: org, user:) @@ -96,12 +97,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:cd_org_link, provider_organization: org, user:) @@ -112,9 +113,9 @@ end context 'no link to org' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'redirects to organizations' do get "/organizations/#{org.id}/ip_addresses/new" expect(response).to redirect_to('/organizations') @@ -122,12 +123,12 @@ end context :not_signed_tos do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'redirects to organizations page' do @@ -138,12 +139,12 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'returns success' do @@ -163,13 +164,13 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let(:org_api_id) { SecureRandom.uuid } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:, dpc_api_organization_id: org_api_id) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'succeeds with valid params' do @@ -237,13 +238,13 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let(:org_api_id) { SecureRandom.uuid } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:, dpc_api_organization_id: org_api_id) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'flashes success if succeeds' do diff --git a/dpc-portal/spec/requests/login_dot_gov_spec.rb b/dpc-portal/spec/requests/login_dot_gov_spec.rb index fc1c13225e..0aa34d5abc 100644 --- a/dpc-portal/spec/requests/login_dot_gov_spec.rb +++ b/dpc-portal/spec/requests/login_dot_gov_spec.rb @@ -5,11 +5,9 @@ RSpec.describe 'LoginDotGov', type: :request do let(:uuid) { SecureRandom.uuid } - describe 'POST /auth/login_dot_gov' do - let!(:csp) { create(:csp, name: :login_dot_gov) } - - RSpec.shared_examples 'an openid client' do + let!(:csp) { create(:csp, :login_dot_gov) } + RSpec.shared_examples 'a login.gov client' do context 'user exists' do before do user = create(:user, email: 'bob1@example.com', provider: :login_dot_gov) @@ -31,7 +29,7 @@ post '/auth/login_dot_gov' follow_redirect! end - it 'should not add another user credential' do + it 'should not add another user' do expect(CspUser.where(uuid:, csp:).count).to eq 1 expect do post '/auth/login_dot_gov' @@ -66,19 +64,19 @@ ial: 'http://idmanagement.gov/ns/assurance/ial/2' } } }) end - it_behaves_like 'an openid client' + it_behaves_like 'a login.gov client' context :user_exists do + let(:db_user) { create(:user, uid: '12345', provider: 'login_dot_gov', email: 'bob@example.com') } before do - user = create(:user, email: 'bob2@example.com') - create(:csp_user, user:, uuid:, csp:) + create(:csp_user, user: db_user, uuid:, csp:) end it 'updates user names' do expect do post '/auth/login_dot_gov' follow_redirect! end.to change { - User.where(email: 'bob2@example.com', given_name: 'Bob', + User.where(id: db_user.id, given_name: 'Bob', family_name: 'Hoskins').count }.by 1 expect(response.location).to eq organizations_url @@ -118,27 +116,32 @@ OmniAuth.config.test_mode = true OmniAuth.config.add_mock(:login_dot_gov, { uid: uuid, - info: { email: 'bob3@example.com' }, - extra: { raw_info: { all_emails: %w[bob3@example.com bobby@example.com], + info: { email: 'bob@example.com' }, + extra: { raw_info: { all_emails: %w[bob@example.com bob2@example.com], ial: 'http://idmanagement.gov/ns/assurance/ial/1' } } }) end - it_behaves_like 'an openid client' + it_behaves_like 'a login.gov client' context :user_exists do before do - user = create(:user, email: 'bob3@example.com', given_name: 'Bob', - family_name: 'Hoskins') - create(:csp_user, user:, uuid:, csp:) + create(:user, provider: 'login_dot_gov', given_name: 'Bob', family_name: 'Hoskins') + create(:csp_user, user: User.last, uuid:, csp:) end it 'does not update user names' do - expect(User.where(email: 'bob3@example.com', given_name: 'Bob', - family_name: 'Hoskins').count).to eq 1 + expect(CspUser.where(uuid:).count).to eq 1 + # expect(User.where(uid: '12345', provider: 'login_dot_gov', email: 'bob@example.com', given_name: 'Bob', + # family_name: 'Hoskins').count).to eq 1 post '/auth/login_dot_gov' follow_redirect! expect(response.location).to eq organizations_url - expect(User.where(email: 'bob3@example.com', given_name: 'Bob', - family_name: 'Hoskins').count).to eq 1 + expect(CspUser.where(uuid:, csp: csp).count).to eq 1 + db_user = CspUser.find_by(uuid:, csp: csp)&.user + expect(db_user).to be_present + expect(db_user.given_name).to eq 'Bob' + expect(db_user.family_name).to eq 'Hoskins' + # expect(User.where(uid: '12345', provider: 'login_dot_gov', email: 'bob@example.com', given_name: 'Bob', + # family_name: 'Hoskins').count).to eq 1 end it 'does not set authentication token' do @@ -179,6 +182,7 @@ context 'should add emails' do before do + uuid = SecureRandom.uuid OmniAuth.config.test_mode = true OmniAuth.config.add_mock(:login_dot_gov, { uid: uuid, @@ -191,7 +195,7 @@ all_emails: %w[email1@example.com email2@example.com], ial: 'http://idmanagement.gov/ns/assurance/ial/2' } } }) - user = create(:user, email: 'email1@example.com', provider: :login_dot_gov) + user = create(:user, provider: :login_dot_gov) create(:csp_user, user:, uuid:, csp:) end @@ -285,9 +289,29 @@ end describe 'Delete /logout' do + before do + uuid = SecureRandom.uuid + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:login_dot_gov, + { uid: uuid, + credentials: { expires_in: 899, + token: 'bearer-token' }, + info: { email: 'email1@example.com' }, + extra: { raw_info: { given_name: 'Bob', + family_name: 'Hoskins', + social_security_number: '1-2-3', + all_emails: %w[email1@example.com email2@example.com], + ial: 'http://idmanagement.gov/ns/assurance/ial/2' } } }) + + user = create(:user, provider: :login_dot_gov) + csp = create(:csp, :login_dot_gov) + create(:csp_user, user:, uuid:, csp:) + post '/auth/login_dot_gov' + follow_redirect! + end it 'should redirect to login.gov' do delete '/logout' - expect(response.location).to include(ENV.fetch('IDP_HOST')) + expect(response.location).to include(ENV.fetch('IDP_LOGIN_DOT_GOV_HOST')) expect(request.session[:user_return_to]).to be_nil end it 'should set return to invitation flow if invitation sent' do @@ -301,14 +325,14 @@ describe 'Get /auth/no_account' do it 'should show logout button' do get '/auth/no_account' - expect(response.body).to include 'Sign out of Login.gov' + expect(response.body).to include 'Sign out of CSP' end end describe 'CSP inactive' do before do - inactive_csp = create(:csp, :inactive) - user = create(:user, email: 'bob5@example.com', provider: :login_dot_gov) + inactive_csp = create(:csp, :id_me, :inactive) + user = create(:user, email: 'bob5@example.com', provider: :id_me) create(:csp_user, user:, uuid:, csp: inactive_csp) OmniAuth.config.test_mode = true @@ -322,7 +346,7 @@ it 'should log error' do allow(Rails.logger).to receive(:info) expect(Rails.logger).to receive(:info).with( - ['User attempted to login with Login.gov but no active CSP found', + ['User attempted to login but no active CSP found', { actionContext: LoggingConstants::ActionContext::Authentication, actionType: LoggingConstants::ActionType::InvalidCsp }] ) diff --git a/dpc-portal/spec/requests/organizations_spec.rb b/dpc-portal/spec/requests/organizations_spec.rb index f62e399dc4..ef9d14596f 100644 --- a/dpc-portal/spec/requests/organizations_spec.rb +++ b/dpc-portal/spec/requests/organizations_spec.rb @@ -17,9 +17,9 @@ end describe 'logged in' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'returns success if no orgs associated with user' do get '/organizations' @@ -40,9 +40,13 @@ end context 'user has sanctions' do - let!(:user) { create(:user, verification_status: 'rejected', verification_reason: 'ao_med_sanctions') } + let!(:user) do + create_user_with_csp(given_name: 'John', family_name: 'Smith', csp: :login_dot_gov, + verification_status: 'rejected', verification_reason: 'ao_med_sanctions') + end + let!(:org) { create(:provider_organization) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -63,8 +67,8 @@ end context 'no link to org' do - let!(:user) { create(:user) } - before { sign_in user } + let!(:user) { create_user_with_csp } + before { sign_in(user:, csp: :login_dot_gov) } it 'redirects to organizations page' do org = create(:provider_organization) get "/organizations/#{org.id}" @@ -74,12 +78,12 @@ context 'ao access denied' do context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -89,12 +93,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -104,9 +108,9 @@ end context 'user no longer ao' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:, verification_status: false, @@ -118,12 +122,12 @@ end context 'cd access denied' do context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:cd_org_link, provider_organization: org, user:) @@ -133,12 +137,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:cd_org_link, provider_organization: org, user:) @@ -149,10 +153,10 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } let!(:link) { create(:cd_org_link, user:, provider_organization: org) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } context :not_signed_tos do it 'should redirect' do @@ -204,11 +208,11 @@ end context 'as ao' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } before do create(:ao_org_link, user:, provider_organization: org) - sign_in user + sign_in(user:, csp: :login_dot_gov) end context :not_signed_tos do @@ -333,8 +337,8 @@ end describe 'AO org flow' do - let!(:user) { create(:user) } - before { sign_in user } + let!(:user) { create_user_with_csp } + before { sign_in(user:, csp: :login_dot_gov) } context 'GET /organizations/new' do it 'returns success' do diff --git a/dpc-portal/spec/requests/public_keys_spec.rb b/dpc-portal/spec/requests/public_keys_spec.rb index 4d2d91f0b1..408d1dbbc1 100644 --- a/dpc-portal/spec/requests/public_keys_spec.rb +++ b/dpc-portal/spec/requests/public_keys_spec.rb @@ -29,9 +29,9 @@ context 'ao access denied' do context 'user has sanctions' do - let!(:user) { create(:user, verification_status: 'rejected', verification_reason: 'ao_med_sanctions') } + let!(:user) { create_user_with_csp(verification_status: 'rejected', verification_reason: 'ao_med_sanctions') } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -42,12 +42,12 @@ end context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -57,12 +57,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:) @@ -72,9 +72,9 @@ end context 'user no longer ao' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:ao_org_link, provider_organization: org, user:, verification_status: false, @@ -86,12 +86,12 @@ end context 'cd access denied' do context 'org has sanctions' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'org_med_sanctions') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:cd_org_link, provider_organization: org, user:) @@ -101,12 +101,12 @@ end context 'org not approved' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) do create(:provider_organization, terms_of_service_accepted_by:, verification_status: 'rejected', verification_reason: 'no_approved_enrollment') end - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'should show access denied page' do create(:cd_org_link, provider_organization: org, user:) @@ -117,9 +117,9 @@ end context 'no link to org' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'redirects to organizations' do get "/organizations/#{org.id}/public_keys/new" expect(response).to redirect_to('/organizations') @@ -127,12 +127,12 @@ end context :not_signed_tos do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'redirects to organizations page' do @@ -143,12 +143,12 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'returns success' do @@ -168,7 +168,7 @@ end describe 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let(:org_api_id) { SecureRandom.uuid } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:, dpc_api_organization_id: org_api_id) } let(:success_params) do @@ -178,7 +178,7 @@ end before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'succeeds with params' do @@ -270,13 +270,13 @@ end context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let(:org_api_id) { SecureRandom.uuid } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:, dpc_api_organization_id: org_api_id) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'flashes success if succeeds' do diff --git a/dpc-portal/spec/requests/users/sessions_spec.rb b/dpc-portal/spec/requests/users/sessions_spec.rb index 09c65ec833..8ee4a46aab 100644 --- a/dpc-portal/spec/requests/users/sessions_spec.rb +++ b/dpc-portal/spec/requests/users/sessions_spec.rb @@ -8,30 +8,43 @@ describe 'logout' do context 'logged in' do - let!(:user) { create(:user) } - before do - sign_in user - end - it 'should prevent access' do - delete '/users/sign_out' - get '/organizations' - expect(response).to redirect_to('/users/sign_in') - expect(flash[:alert]).to be_present + shared_examples 'logout actions' do |provider| + let!(:csp) { create(:csp, provider) } + let!(:user) do + user = create(:user, provider:) + create(:csp_user, user:, uuid: SecureRandom.uuid, csp:) + user + end + before { sign_in user:, csp: provider } + it 'should prevent access' do + delete '/users/sign_out' + get '/organizations' + expect(response).to redirect_to('/users/sign_in') + expect(flash[:alert]).to be_present + end + + it 'should log action' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with( + ['User logged out', + { actionContext: LoggingConstants::ActionContext::Authentication, + actionType: LoggingConstants::ActionType::UserLoggedOut }] + ) + delete '/users/sign_out' + end + + it 'should redirect to the provider host' do + delete '/users/sign_out' + expect(response.location).to include(ENV.fetch("IDP_#{provider.to_s.upcase}_HOST")) + end end - it 'should log action' do - allow(Rails.logger).to receive(:info) - expect(Rails.logger).to receive(:info).with( - ['User logged out', - { actionContext: LoggingConstants::ActionContext::Authentication, - actionType: LoggingConstants::ActionType::UserLoggedOut }] - ) - delete '/users/sign_out' + context 'using Login.gov' do + include_examples 'logout actions', :login_dot_gov end - it 'should redirect to login.gov' do - delete '/users/sign_out' - expect(response.location).to include(ENV.fetch('IDP_HOST')) + context 'using ID.me' do + include_examples 'logout actions', :id_me end end diff --git a/dpc-portal/spec/services/auto_session_logout_service_spec.rb b/dpc-portal/spec/services/auto_session_logout_service_spec.rb index 5408bb0e32..77cad0af57 100644 --- a/dpc-portal/spec/services/auto_session_logout_service_spec.rb +++ b/dpc-portal/spec/services/auto_session_logout_service_spec.rb @@ -7,7 +7,7 @@ include LoginSupport let(:user) { create(:user) } - before { sign_in user } + before { sign_in(user:, csp: :login_dot_gov) } it 'is active' do get '/active' diff --git a/dpc-portal/spec/services/user_info_service_spec.rb b/dpc-portal/spec/services/user_info_service_spec.rb index 7e05896bc7..7d3b5ec546 100644 --- a/dpc-portal/spec/services/user_info_service_spec.rb +++ b/dpc-portal/spec/services/user_info_service_spec.rb @@ -4,44 +4,19 @@ require 'rails_helper' describe UserInfoService do - let(:user_info_url) { UserInfoService::USER_INFO_URI } let(:service) { UserInfoService.new } let(:token) { 'bearer-token' } let(:exp) { 2.hours.from_now } - let(:valid_session) { { login_dot_gov_token: token, login_dot_gov_token_exp: exp } } - context :valid_session do - let(:response) do - { - 'sub' => '097d06f7-e9ad-4327-8db3-0ba193b7a2c2', - 'iss' => 'https://idp.int.identitysandbox.gov/', - 'email' => 'david@example.com', - 'email_verified' => true, - 'all_emails' => [ - 'david@example.com', - 'david2@example.com' - ], - 'given_name' => 'David', - 'family_name' => 'Davis', - 'birthdate' => '1938-10-06', - 'social_security_number' => '900888888', - 'phone' => '+19174216435', - 'phone_verified' => true, - 'verified_at' => 1_704_834_157, - 'ial' => 'http://idmanagement.gov/ns/assurance/ial/2', - 'aal' => 'urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo' - } - end - before do - stub_request(:get, user_info_url) + stub_request(:get, user_info_url(:login_dot_gov)) .with(headers: { Authorization: "Bearer #{token}" }) - .to_return(body: response.to_json, status: 200) + .to_return(body: csp_response(:login_dot_gov).to_json, status: 200) end it 'should return info with valid session' do verify_logs(status: 200) - expect(service.user_info(valid_session)).to eq response + expect(service.user_info(valid_csp_session(:login_dot_gov))).to eq csp_response(:login_dot_gov) end end @@ -49,43 +24,43 @@ it 'should throw error if status is 401' do verify_logs(status: 401) error = '{"error":"No can do"}' - stub_request(:get, user_info_url) + stub_request(:get, user_info_url(:login_dot_gov)) .with(headers: { Authorization: "Bearer #{token}" }) .to_return(body: error, status: 401) expect do - service.user_info(valid_session) + service.user_info(valid_csp_session(:login_dot_gov)) end.to raise_error(UserInfoServiceError, 'unauthorized') end it 'should throw error if status is 500' do verify_logs(status: 500) error = '{"error":"shrug"}' - stub_request(:get, user_info_url) + stub_request(:get, user_info_url(:login_dot_gov)) .with(headers: { Authorization: "Bearer #{token}" }) .to_return(body: error, status: 500) expect do - service.user_info(valid_session) + service.user_info(valid_csp_session(:login_dot_gov)) end.to raise_error(UserInfoServiceError, 'server_error') end it 'should throw error if cannot connect' do verify_logs(status: 503) - stub_request(:get, user_info_url) + stub_request(:get, user_info_url(:login_dot_gov)) .with(headers: { Authorization: "Bearer #{token}" }) .to_raise(Errno::ECONNREFUSED) expect do - service.user_info(valid_session) + service.user_info(valid_csp_session(:login_dot_gov)) end.to raise_error(UserInfoServiceError, 'server_error') end end context :invalid_session do it 'should throw error if no token' do - invalid = valid_session.merge(login_dot_gov_token: nil) + invalid = valid_csp_session(:login_dot_gov).merge(login_dot_gov_token: nil) expect do service.user_info(invalid) end.to raise_error(UserInfoServiceError, 'no_token') end it 'should throw error if no token expiration' do - invalid = valid_session.merge(login_dot_gov_token_exp: nil) + invalid = valid_csp_session(:login_dot_gov).merge(login_dot_gov_token_exp: nil) expect do service.user_info(invalid) end.to raise_error(UserInfoServiceError, 'no_token_exp') @@ -94,39 +69,65 @@ let(:exp) { 1.second.ago } it 'should throw error' do expect do - service.user_info(valid_session) + service.user_info(valid_csp_session(:login_dot_gov)) end.to raise_error(UserInfoServiceError, 'expired_token') end end end - def verify_logs(status:) - verify_new_relic - verify_rails(status) + def verify_logs(status:, csp: 'login_dot_gov') + verify_new_relic(csp) + verify_rails(status: status, csp: csp) end - def verify_new_relic + def verify_new_relic(csp) new_relic_tracer = instance_double(NewRelic::Agent::Transaction::ExternalRequestSegment) expect(NewRelic::Agent::Tracer).to receive(:start_external_request_segment) - .with(library: 'Net::HTTP', uri: user_info_url, procedure: :get) + .with(library: 'Net::HTTP', uri: user_info_url(csp), procedure: :get) .and_return(new_relic_tracer) expect(new_relic_tracer).to receive(:finish) end - def verify_rails(status) + def verify_rails(status:, csp:) allow(Rails.logger).to receive(:info) expect(Rails.logger).to receive(:info).with( - ['Calling Login.gov user_info', - { login_dot_gov_request_method: :get, - login_dot_gov_request_url: user_info_url, - login_dot_gov_request_method_name: :request_info }] + ['Calling CSP user_info', + { csp: csp, + csp_request_method: :get, + csp_request_url: user_info_url(csp), + csp_request_method_name: :request_info }] ) expect(Rails.logger).to receive(:info).with( - ['Login.gov user_info response info', - { login_dot_gov_request_method: :get, - login_dot_gov_request_url: user_info_url, - login_dot_gov_request_method_name: :request_info, - login_dot_gov_response_status_code: status, - login_dot_gov_response_duration: anything }] + ['CSP user_info response info', + { csp: csp, + csp_request_method: :get, + csp_request_url: user_info_url(csp), + csp_request_method_name: :request_info, + csp_response_status_code: status, + csp_response_duration: anything }] ) end + + def valid_csp_session(csp) + csp = csp.to_s + session = ActiveSupport::HashWithIndifferentAccess.new + session[:csp] = csp + session["#{csp}_token"] = token + session["#{csp}_token_exp"] = exp + session + end + + def csp_response(csp) + file_path_components = ['csps', csp.to_s, 'user_info.json'] + file_path = File.join(*file_path_components) + json_fixture(file_path) + end + + def user_info_url(csp) + case csp.to_s + when 'login_dot_gov' then LOGIN_DOT_GOV_CLIENT_CONFIG[:client_options][:userinfo_endpoint] + when 'id_me' then ID_ME_CLIENT_CONFIG[:client_options][:userinfo_endpoint] + # when 'clear' then CspConfig::CLEAR.user_info_endpoint + else raise ArgumentError, "Unknown CSP code: #{csp}" + end + end end diff --git a/dpc-portal/spec/support/credential_resource_shared_examples.rb b/dpc-portal/spec/support/credential_resource_shared_examples.rb index a9382ceadd..7364e46599 100644 --- a/dpc-portal/spec/support/credential_resource_shared_examples.rb +++ b/dpc-portal/spec/support/credential_resource_shared_examples.rb @@ -5,13 +5,13 @@ RSpec.shared_examples 'a credential resource' do describe 'Post /create' do context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let(:org_api_id) { SecureRandom.uuid } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:, dpc_api_organization_id: org_api_id) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'adds a credential audit log record on success' do token_guid = SecureRandom.uuid @@ -41,13 +41,13 @@ describe 'Delete /destroy' do context 'as cd' do - let!(:user) { create(:user) } + let!(:user) { create_user_with_csp } let(:org_api_id) { SecureRandom.uuid } let!(:org) { create(:provider_organization, terms_of_service_accepted_by:, dpc_api_organization_id: org_api_id) } before do create(:cd_org_link, provider_organization: org, user:) - sign_in user + sign_in(user:, csp: :login_dot_gov) end it 'adds a credential audit log record on success' do diff --git a/dpc-portal/spec/support/fake_cpi_gateway.rb b/dpc-portal/spec/support/fake_cpi_gateway.rb index f1c64bba0d..506cd76315 100644 --- a/dpc-portal/spec/support/fake_cpi_gateway.rb +++ b/dpc-portal/spec/support/fake_cpi_gateway.rb @@ -86,7 +86,7 @@ class FakeCpiGateway < Sinatra::Base } }.to_json else - ao_ssns = %w[900111111 900666666 900777777 900888888 666222222] + ao_ssns = %w[900111111 900666666 900777777 900888888 666222222 111887777 512111111] roles = ao_ssns.map { |ssn| { pacId: ssn, roleCode: '10', ssn: } } roles << { pacId: 'validPacId', roleCode: '10', ssn: '900428421' } provider = { diff --git a/dpc-portal/spec/support/fixture_helper.rb b/dpc-portal/spec/support/fixture_helper.rb new file mode 100644 index 0000000000..78f1bad2a9 --- /dev/null +++ b/dpc-portal/spec/support/fixture_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module FixtureHelper + def read_file(path) + File.read(Rails.root.join('spec', 'fixtures', path)) + end + + # Optional helper to immediately parse it into a Ruby Hash/Array + def json_fixture(path) + JSON.parse(read_file(path)) + end +end diff --git a/dpc-portal/spec/support/login_support.rb b/dpc-portal/spec/support/login_support.rb index d75f49924a..083831b01b 100644 --- a/dpc-portal/spec/support/login_support.rb +++ b/dpc-portal/spec/support/login_support.rb @@ -3,26 +3,69 @@ require 'securerandom' module LoginSupport - def sign_in(user) - defaults(user) + def create_user_with_csp(given_name: 'John', family_name: 'Smith', csp: :login_dot_gov, + uuid: SecureRandom.uuid, **user_attrs) + csp = create(:csp, csp) + user = create(:user, given_name:, family_name:, **user_attrs) + create(:csp_user, user:, uuid:, csp:) + user + end + + def create_user_and_sign_in(given_name: 'John', family_name: 'Smith', csp: :login_dot_gov, uuid: SecureRandom.uuid) + user = create_user_with_csp(given_name:, family_name:, csp:, uuid:) + sign_in user:, csp: + user + end - csp = create(:csp, name: user.provider) - csp_user = create(:csp_user, user:, csp:, uuid: user.uid) + def sign_in(user:, csp:) OmniAuth.config.test_mode = true - OmniAuth.config.add_mock(csp.name, - { uid: csp_user.uuid, - info: { email: user.email }, - extra: { raw_info: { all_emails: [user.email], - ial: 'http://idmanagement.gov/ns/assurance/ial/1' } } }) - post '/auth/login_dot_gov' + case csp.to_s + when 'id_me' + OmniAuth.config.add_mock(:id_me, id_me_auth_hash(user)) + when 'login_dot_gov' + OmniAuth.config.add_mock(:login_dot_gov, login_dot_gov_auth_hash(user)) + when 'clear' + OmniAuth.config.add_mock(:clear, clear_auth_hash(user)) + else raise ArgumentError, "Unknown CSP code: #{csp}" + end + post "/auth/#{csp}" follow_redirect! end - private + def login_dot_gov_auth_hash(user) + { uid: user.csp_user_for('login_dot_gov')&.uuid || user.uid, + info: { email: user.email }, + # credentials: { token: 'mock_token', expires_in: 300 }, + extra: { + raw_info: { + all_emails: [user.email], + ial: 'http://idmanagement.gov/ns/assurance/ial/1' + } + } } + end + + def id_me_auth_hash(user) + { uid: user.csp_user_for('id_me')&.uuid || user.uid, + info: { email: user.email }, + # credentials: { token: 'mock_token', expires_in: 300 }, + extra: { + raw_info: { + SSN: 111_887_777, + identity_assurance_level: 1, + emails_confirmed: [user.email], + email: user.email + } + } } + end - # Sets default values required for auth if not already set. - def defaults(user) - user.uid = SecureRandom.uuid if user.uid.nil? - user.provider = :login_dot_gov if user.provider.nil? + def clear_auth_hash(user) + { uid: user.csp_user_for('clear')&.uuid || user.uid, + info: { email: user.email }, + extra: { + raw_info: { + all_emails: [user.email], + ial: 'http://idmanagement.gov/ns/assurance/ial/1' + } + } } end end diff --git a/dpc-portal/spec/system/accessibility_spec.rb b/dpc-portal/spec/system/accessibility_spec.rb index 6e1c6441b2..30ecde18af 100644 --- a/dpc-portal/spec/system/accessibility_spec.rb +++ b/dpc-portal/spec/system/accessibility_spec.rb @@ -12,18 +12,18 @@ let(:dpc_api_organization_id) { 'some-gnarly-guid' } let(:axe_standard) { %w[best-practice wcag21aa] } let(:uid) { SecureRandom.uuid } - let!(:csp) { create(:csp, name: :login_dot_gov) } + let!(:csp) { create(:csp, name: :id_me) } before do OmniAuth.config.test_mode = true - OmniAuth.config.add_mock(:login_dot_gov, + OmniAuth.config.add_mock(:id_me, { uid:, info: { email: 'bob@example.com' }, extra: { raw_info: { all_emails: %w[bob@example.com bob2@example.com], ial: 'http://idmanagement.gov/ns/assurance/ial/1' } } }) end def sign_in - visit '/auth/login_dot_gov/callback' + visit '/auth/id_me/callback' end context 'login' do it 'shows login page ok' do @@ -40,14 +40,14 @@ def sign_in context 'bad user tries to log in' do it 'shows no such user page' do - visit '/auth/login_dot_gov/callback' + visit '/auth/id_me/callback' expect(page).to have_text('The email you used is not associated with a DPC account.') expect(page).to be_axe_clean.according_to axe_standard end it 'shows sanctioned ao page' do user = create(:user, verification_status: 'rejected', verification_reason: 'ao_med_sanctions') create(:csp_user, user:, csp:, uuid: uid) - visit '/auth/login_dot_gov/callback' + visit '/auth/id_me/callback' expect(page).to have_text(I18n.t('verification.ao_med_sanctions_status')) expect(page).to be_axe_clean.according_to axe_standard end @@ -57,7 +57,7 @@ def sign_in it 'shows success page' do user = create(:user, verification_status: 'approved') create(:csp_user, user:, csp:, uuid: uid) - visit '/auth/login_dot_gov/callback' + visit '/auth/id_me/callback' expect(page).to have_text("You don't have any organizations to show.") expect(page).to be_axe_clean.according_to axe_standard end diff --git a/dpc-portal/spec/system/new_invitation_spec.rb b/dpc-portal/spec/system/new_invitation_spec.rb index b146459027..b070acd06e 100644 --- a/dpc-portal/spec/system/new_invitation_spec.rb +++ b/dpc-portal/spec/system/new_invitation_spec.rb @@ -13,19 +13,19 @@ before do OmniAuth.config.test_mode = true - OmniAuth.config.add_mock(:login_dot_gov, + OmniAuth.config.add_mock(:id_me, { uid:, info: { email: 'bob@example.com' }, extra: { raw_info: { all_emails: %w[bob@example.com bob2@example.com], ial: 'http://idmanagement.gov/ns/assurance/ial/1' } } }) end def sign_in - visit '/auth/login_dot_gov/callback' + visit '/auth/id_me/callback' end context 'CD invite' do let(:dpc_api_organization_id) { 'some-gnarly-guid' } let!(:user) { create(:user) } - let!(:csp) { create(:csp, name: :login_dot_gov) } + let!(:csp) { create(:csp, name: :id_me) } let!(:csp_user) { create(:csp_user, user_id: user.id, csp:, uuid: uid) } let!(:org) { create(:provider_organization, dpc_api_organization_id:, name: 'Health Hut') } let!(:ao_org_link) { create(:ao_org_link, user:, provider_organization: org) } diff --git a/ops/config/encrypted/local.env b/ops/config/encrypted/local.env index c992102087..5840281b85 100644 --- a/ops/config/encrypted/local.env +++ b/ops/config/encrypted/local.env @@ -1,107 +1,22 @@ $ANSIBLE_VAULT;1.1;AES256 -34343139303238333833313866353535373461383839386265343865396335326230626662363865 -6234653135616565666265323866616336393632666663350a363764306466373666613433313436 -62646561366630356532613138353135653739343339333632323266666133316239323362373463 -6237646366643231370a303764626363303830653932666434323362346162386630613261656334 -61303965323331333535363861626263626166666631386639616461373766356163646536396564 -64373731666366376263343738636235333334653661323130393839306637333065623936373330 -61383438356236313738396439633063303737333538316561373032346339653135626139613663 -65363866326665383637343064383831653561626135636661656162636230303631633464613635 -66323365646662376163383765396462663766313365656537313631303838316534343763323865 -61363835636437316439663561666661383764623863356636393230303336623737313866646263 -34653662316162383837613836666266623065623965346330636133363135633862303938363462 -33376430303861376631356536363061323235616633623434316637376632363834323333653665 -34306464303339316461346337633135323630653930396431333334366462643032363061353133 -61663830633331663234613134306338373635663131363038666661343231363331623430343038 -61656437346466666334303533626236353430393430666339363764613839656536346135633430 -36316266373562663863323864393835386562363932333739316366616639373836656438373337 -61626437316365363931616163656536623330636365343139333434633532656265623831303466 -35353032613166356261303230366261336535356637663261323731626366623665346236633033 -36363634303565313966323336386439626238356639653037623461396432343437626634636630 -62373664383437623461346166373066303334646263653235656665306461626663633238303130 -34666662656464623466316363373463316637323232386439376535363366313363333038383964 -31306462376431303836356166316636613636393262336334383232616630323063373464313232 -33313139306339666630303334333663643164303535373133313861323066383766653266666665 -35623637613533323933636538303439363933646338613065363639343432323938343764336435 -32383430636263366433313331613763326161613863393139613866353563653865646362333866 -65666562376435663230383638303433353539623935323337383064303234643466316364366433 -61386464633331346563666230653334653863343338366365396665376636663166623566646137 -63646131646334633034343837616231666531636336613039303764646466653735313333376665 -34323638613264316139383165383536666439353538613337383863373533633131366434303339 -36653538373063636237376564616335386436383638303638353734386237373363316238656437 -36353364346230656237663163613661363763376562376339663234626434636136363830363166 -30323164393865646335666264633663333266343538393266333733326562303732303232366364 -39376335396263353464393531333862663435376136643066353363383030373835626364333939 -64396264326434663835343735373338346137316135333731353033316565646566346363323832 -63643138316562336436363962383936663963376538616634616439623532356437303831373664 -65343562383733636166373133616532623634653136613438363536353234653938333832323136 -37386439323638316134333533343532366663303733646430626435613563393835653763376434 -61373831386136626131363864396639303239643130303762633365363039303239333437636461 -35333534396336363134373539353037643062646234343737346135376332343465303766613565 -61653961616664646462373864636331356334646562343164353032326261313265353866303162 -62313139393639653634646134393630613133663730623637336565323865623232623666343937 -64326534393533636239623262376234386136336435396236623362313732656165306665383965 -63383963313266323639303332646134376561613964313262363330316436356337326664643037 -65616664376264623661663764616131363934323162613938626265356532336531623237633661 -65313135326662643361613965313334343135653337326366396531363364636365613262323836 -36306533373739343631366666633463626538383034336566313330396533366530613861383036 -35393032626638343337373230613530383564643236353664616165326262613336306137396236 -32626431653732396662356435353462316266623636353837613036333665316338346130363935 -37383464386335353462353761386263343363616530303964316463353765333939333432383739 -38316232383630306237333337306334346664653334613365646565373433616233663261376330 -39356561636638623265353336333339346431633637306538633930396666303230386431653163 -35306234616436366138386133386135653731633266373731663864313630636663353761636461 -30303035643439353731316363393032636365323739643735393363373135643439653434316534 -63386462653232313866633961306366353937613466356662646265393863356539316134633635 -65306239343437343965623761623934393038363464313961356434316533383734316464383165 -36353034393166646435396264343939666165333837623462396238323130363063623563323038 -39383961636665396532336235643266656638346431623061653431323036616264613962626663 -33313336303933333163623663366164303237386166646565343334363330396338613936363232 -33343330396639613565363361653665346265363161633836376133323932353065303336333862 -32643935306430303835616666633661316533663534356262633631306233626234666636666666 -37316433393939396661323066633831303766313031333839343036393861353330316663316437 -36346333396634313662393030326464353336316366343930346233353235633335666336646463 -65633132303736303835346365646537376161376637333833373362653463613439376430393964 -38356534643739663763333039313034393037316434643964313066333862666330313066383638 -38356462303962333432393834663330333939663333396533613533363037643766663938386261 -35623764663061396661626465653133623163383262386136353039366332353030336335626561 -64383731393265353965323163376237633365613061643666623534643165396434616666343138 -37623635313962663061353936653062393734333861656664656138353966326638383064613130 -30663362353137353064386533303130303232306662663932613537323962363132663763643036 -37666163653262353063386365643538303730646331633463343733376663643862366632316432 -38383464376635376635626462353162356566633734633738323135323438313231336462306537 -30656532653761663164613835313062326561316562336632306530326238373735616339313763 -39353331626463316363323861303136616431363565343334323335313363376462666262323135 -32366131303431333830363635313730366339656235366530636136396163653933326234396139 -36323938346161323065383538643134393930393138643238366134643365643433616266396434 -36633535646434306134663038336338633437653933666439636335323534613764303364313165 -33373962313666373465653931326433333965666561646666353064316261323661306230653436 -39376238353637633062363865333833306130616232333736336366653436303736353762626566 -64613932633764613237353133633264363065313866313135653433653661623164376534623735 -37333438306261633334353936363838653766386165393837626139353861336637303761343639 -38396462636233383938346361343136623431653039383933353637323332626662336365653763 -36373039396430383037666364323033383966323830313562656562653137623363346234613135 -65666662303032323037336336643234623164623065653163663037326432666135643765333032 -30316237623163396430333438646461633136396638363938653263613837333031663232303562 -65383338323437383061663731663536373963336139333363626630306133356266316637383733 -32316238353061343264613464343432313932303066663732333564393433366235343864333334 -65316562626138356532316530653433373633326437303235333737346630613131646434666164 -61346238666266666531316136643737663362343266623336613536336265386138353564333239 -36386534386563393139646333623864366435353936323333363930623530643939646237656562 -33663461306136663965333237386235656433376333306662636339653564396534656166623536 -33643530643432643931313766623363356266393432373138306533356363313366656536303631 -30393436366135363864373464613834323737333736363766613865613434323735666333636631 -63613664353636636466303439396362306363626538393330656161626463653039616338316330 -35316463353431663461333661346461613561633635303262336465326564383838343839646133 -66623339356365316130316466326133366631393631393236316665356233336361313462356339 -34616266356462323065623739613233353465366636653732386161646665313166636663306463 -39333463316433383466386164633464636633613562653032616435373732626232356161393361 -31346334313162373832393765316137303834643864623163643862663732613265633162646336 -32353962643032646262346430313036323432396139646534393032336437386231383463613630 -65613232643362326131643634383535373761653965363735643331373535323339363331623235 -36613935366537306137633062303465316131366564653739656335646365356339623763623238 -34383533666135343362646330363334633837306537663339376363316237613161333633353735 -62333437306535356561613030643131376361316436383735663637373031353838656461643262 -36393437613236306566393635323762653762363165353538616262316533336563376635313632 -66353162616436303938353831313536353434646335333235333433336463303062623938366338 -6235663334336432323863623535366562616236376236646539 +62306138653838363865333335643966383036613163643163353366623533653634646332313237 +6662616533383534616436383837653662613861633963360a316665646334316462663639613838 +30636238363035313931643062643665636432333762623162653563343833653561653837373034 +3466613234653330640a666366663863326237643033633735363334316637663639306362373633 +66363338326534626334363439356531396337353538333261373334356638356133633665626262 +30353061393065313161663335383338383239383563323861613234333661643138656262656233 +64373263623736643564356535303265653536383533353431393331626537373861396539623336 +34663366346363373762643664306336313537363863386133633765626533303365353566303365 +39633833343337386139303534613731633731666637616563363133343138363231323830643139 +64386233643962353362316130396433663662393130626538336236366135613963613832346630 +32626566346133383937633238333466643136323765373136343038353265616637343930616230 +63303463303835363534653432646430303762623663303662653535313636633030363336363232 +38343462663836303239343132643034633764643939353263313736336238343539626236643638 +30313366633733386131363765323561366636666530376338353563393862336231376436366331 +34303339623531653334346430613230323363356663633036333762333533333639343963303962 +33323433616133643166633036363636633537373463313335343964616131336631373834333737 +39346330366564306539623162646266313739353832396264613162303032393763343665653634 +30333765633034613937346532623363656631646461363335623261646165643730323532623066 +38333064373636336466353831326362636133353932313030373735623061356533653335643235 +64633334653736326139653065626331636166303932363433636232653130333534303062643065 +35343866373530306461396563383963653732623538623061313766623230353130