diff --git a/Gemfile b/Gemfile
index 72c0c7bdf82..291ab73a93b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -70,7 +70,7 @@ gem 'rqrcode'
gem 'ruby-progressbar'
gem 'ruby-saml'
gem 'safe_target_blank', '>= 1.0.2'
-gem 'saml_idp', github: '18F/saml_idp', tag: '0.19.3-18f'
+gem 'saml_idp', github: '18F/saml_idp', tag: '0.20.0-18f'
gem 'scrypt'
gem 'simple_form', '>= 5.0.2'
gem 'stringex', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 1b99491b670..89c453cc548 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -34,8 +34,8 @@ GIT
GIT
remote: https://github.com/18F/saml_idp.git
- revision: 95369fdd9336773b9983c8de71eb35a8c92e9683
- tag: 0.19.3-18f
+ revision: f86b4c5ef4281a53b3f13a1db2c2e5839fdf077d
+ tag: 0.20.0-18f
specs:
saml_idp (0.19.3.pre.18f)
activesupport
@@ -560,7 +560,7 @@ GEM
ffi (~> 1.0)
rdoc (6.6.2)
psych (>= 4.0.0)
- redacted_struct (1.1.0)
+ redacted_struct (2.0.0)
redcarpet (3.6.0)
redis (5.0.6)
redis-client (>= 0.9.0)
diff --git a/app/assets/images/logo.png b/app/assets/images/email/logo.png
similarity index 100%
rename from app/assets/images/logo.png
rename to app/assets/images/email/logo.png
diff --git a/app/components/login_button_component.html.erb b/app/components/login_button_component.html.erb
index eb808be865a..7fa458e02e4 100644
--- a/app/components/login_button_component.html.erb
+++ b/app/components/login_button_component.html.erb
@@ -3,5 +3,5 @@
**tag_options,
class: css_class,
) do %>
- Sign in with <%= content_tag(:span, APP_NAME, class: 'login-button__logo') %>
-<% end %>
\ No newline at end of file
+ Sign in with <%= inject_svg %>
+<% end %>
diff --git a/app/components/login_button_component.rb b/app/components/login_button_component.rb
index 25b3a7d42b1..b6e16c74ac7 100644
--- a/app/components/login_button_component.rb
+++ b/app/components/login_button_component.rb
@@ -3,18 +3,40 @@
class LoginButtonComponent < BaseComponent
VALID_COLORS = ['primary', 'primary-darker', 'primary-lighter'].freeze
- attr_reader :color, :big, :tag_options
+ attr_reader :color, :big, :width, :height, :tag_options
def initialize(color: 'primary', big: false, **tag_options)
if !VALID_COLORS.include?(color)
raise ArgumentError, "`color` #{color}} is invalid, expected one of #{VALID_COLORS}"
end
-
@big = big
+ @width = big ? '11.1rem' : '7.4rem'
+ @height = big ? '1.5rem' : '1rem'
@color = color
@tag_options = tag_options
end
+ def svg
+ Rails.root.join(
+ 'app', 'assets', 'images',
+ (color == 'primary-darker' ? 'logo-white.svg' : 'logo.svg')
+ ).read
+ end
+
+ def inject_svg
+ # rubocop:disable Rails/OutputSafety
+ Nokogiri::HTML5.fragment(svg).tap do |doc|
+ doc.at_css('svg').tap do |svg|
+ svg[:role] = 'img'
+ svg[:class] = 'login-button__logo'
+ svg[:width] = width
+ svg[:height] = height
+ svg << "
2. {t('doc_auth.headings.document_capture_subheader_selfie')}
+ {t('doc_auth.info.selfie_capture_content')}
{flowPath === 'hybrid' && }
{pageHeaderText}
-
+ {isSelfieCaptureEnabled && (
+
+ )}
void): unknown;
initialize: AcuantInitialize;
START_FAIL_CODE: string;
REPEAT_FAIL_CODE: string;
@@ -268,6 +269,16 @@ function AcuantContextProvider({
loadAcuantSdk();
}
window.AcuantJavascriptWebSdk = getActualAcuantJavascriptWebSdk();
+
+ // Unclear if/how this is called. Implemented just in case, but this is untested.
+ window.AcuantJavascriptWebSdk.setUnexpectedErrorCallback((errorMessage) => {
+ trackEvent('idv_sdk_error_before_init', {
+ success: false,
+ error_message: errorMessage,
+ liveness_checking_required: isSelfieCaptureEnabled,
+ });
+ });
+
window.AcuantJavascriptWebSdk.initialize(credentials, endpoint, {
onSuccess: () => {
window.AcuantJavascriptWebSdk.start?.(() => {
diff --git a/app/javascript/packages/document-capture/hooks/use-log-camera-info.ts b/app/javascript/packages/document-capture/hooks/use-log-camera-info.ts
new file mode 100644
index 00000000000..3851e449fe2
--- /dev/null
+++ b/app/javascript/packages/document-capture/hooks/use-log-camera-info.ts
@@ -0,0 +1,130 @@
+import { useEffect, useContext, useRef } from 'react';
+import AnalyticsContext from '../context/analytics';
+
+type TrackEventType = (event: string, payload?: object | undefined) => void;
+interface CameraLog {
+ label: string;
+ frameRate: number | undefined;
+ height: number | undefined;
+ width: number | undefined;
+}
+type CameraLogs = (CameraLog | undefined)[];
+
+function getConstraints(deviceId: string) {
+ return {
+ video: {
+ width: {
+ ideal: 999999,
+ },
+ height: {
+ ideal: 999999,
+ },
+ deviceId: {
+ exact: deviceId,
+ },
+ },
+ };
+}
+
+function getCameraInfo(videoTrack: MediaStreamTrack) {
+ const cameraInfo = {
+ label: videoTrack.label,
+ frameRate: videoTrack.getSettings().frameRate,
+ height: videoTrack.getSettings().height,
+ width: videoTrack.getSettings().width,
+ };
+ return cameraInfo;
+}
+
+async function updateConstraintsAndGetLogInfo(
+ videoDevice: MediaDeviceInfo,
+ trackEvent: TrackEventType,
+) {
+ // See https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode
+ const updatedConstraints = getConstraints(videoDevice.deviceId);
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia(updatedConstraints);
+ const videoTracks = stream.getVideoTracks();
+ const cameras = videoTracks.map((videoTrack) => getCameraInfo(videoTrack));
+ return cameras[0];
+ } catch (error) {
+ trackEvent('idv_camera_info_error', { error });
+ }
+}
+
+function logsHaveSameValuesButDifferentName(logOne: CameraLog, logTwo: CameraLog) {
+ if (
+ logOne.height === logTwo.height &&
+ logOne.width === logTwo.width &&
+ logOne.frameRate === logTwo.frameRate
+ ) {
+ return true;
+ }
+ return false;
+}
+
+function condenseCameraLogs(cameraLogs: CameraLogs) {
+ // Group logs into sets based on height/width/framerate and return one log for each
+ // Go from this:
+ // [
+ // { label: 'Front Camera', height: 3024, width: 4032, frameRate: 30}]
+ // { label: 'Back Triple Camera', height: 3024, width: 4032, frameRate: 30}]
+ // { label: 'Back Dual Wide Camera', height: 3024, width: 4032, frameRate: 30}]
+ // ]
+ // To this:
+ // [{ label: 'Front Camera, Back Triple Camera, Back Dual Wide Camera', height: 3024, width: 4032, frameRate: 30}]
+ const initialArray: CameraLog[] = [];
+ const condensedLogs = cameraLogs.reduce((accumulator, currentLog) => {
+ for (let i = 0; i < accumulator.length; i++) {
+ const recordedLog: CameraLog = accumulator[i];
+ if (currentLog && logsHaveSameValuesButDifferentName(currentLog, recordedLog)) {
+ // Append to the label field for that log in condensed logs
+ const newLabel = `${recordedLog.label}, ${currentLog.label}`;
+ accumulator[i].label = newLabel;
+ return accumulator;
+ }
+ }
+ // Add a new log to condensed logs, when it doesn't match the existing ones
+ if (currentLog) {
+ return accumulator.concat(currentLog);
+ }
+ return accumulator;
+ }, initialArray);
+ return condensedLogs;
+}
+
+async function logCameraInfo(trackEvent: TrackEventType) {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const videoDevices = devices.filter((device) => device.kind === 'videoinput');
+ const cameraLogs = await Promise.all(
+ videoDevices.map((videoDevice) => updateConstraintsAndGetLogInfo(videoDevice, trackEvent)),
+ );
+ const condensedCameraLogs = condenseCameraLogs(cameraLogs);
+ trackEvent('idv_camera_info_logged', { camera_info: condensedCameraLogs });
+}
+
+// This function is intended to be used only after camera permissions have been granted
+// hasStartedCropping only happens after an image has been captured with the Acuant SDK,
+// which means that camera permissions have been granted.
+function useLogCameraInfo({
+ isBackOfId,
+ hasStartedCropping,
+}: {
+ isBackOfId: boolean;
+ hasStartedCropping: boolean;
+}) {
+ const didLogCameraInfoRef = useRef(false);
+ const { trackEvent } = useContext(AnalyticsContext);
+
+ useEffect(() => {
+ if (!isBackOfId) {
+ return;
+ }
+ if (hasStartedCropping && !didLogCameraInfoRef.current) {
+ logCameraInfo(trackEvent);
+ didLogCameraInfoRef.current = true;
+ }
+ }, [didLogCameraInfoRef, hasStartedCropping, isBackOfId, trackEvent]);
+}
+
+export { useLogCameraInfo };
diff --git a/app/javascript/packages/webauthn/enroll-webauthn-device.spec.ts b/app/javascript/packages/webauthn/enroll-webauthn-device.spec.ts
index be434df81d8..2e0baaf57d2 100644
--- a/app/javascript/packages/webauthn/enroll-webauthn-device.spec.ts
+++ b/app/javascript/packages/webauthn/enroll-webauthn-device.spec.ts
@@ -62,6 +62,7 @@ describe('enrollWebauthnDevice', () => {
challenge,
excludeCredentials,
authenticatorAttachment: 'cross-platform',
+ hints: ['security-key'],
});
expect(navigator.credentials.create).to.have.been.calledWith({
@@ -85,8 +86,9 @@ describe('enrollWebauthnDevice', () => {
timeout: 800000,
attestation: 'none',
authenticatorSelection: {
- authenticatorAttachment: 'cross-platform',
userVerification: 'discouraged',
+ authenticatorAttachment: 'cross-platform',
+ hints: ['security-key'],
},
excludeCredentials: [
{
@@ -132,12 +134,14 @@ describe('enrollWebauthnDevice', () => {
challenge,
excludeCredentials,
authenticatorAttachment: 'platform',
+ hints: ['client-device'],
});
expect(navigator.credentials.create).to.have.been.calledWithMatch({
publicKey: {
authenticatorSelection: {
authenticatorAttachment: 'platform',
+ hints: ['client-device'],
},
},
});
diff --git a/app/javascript/packages/webauthn/enroll-webauthn-device.ts b/app/javascript/packages/webauthn/enroll-webauthn-device.ts
index ffbae3a19cf..2dae75c71d3 100644
--- a/app/javascript/packages/webauthn/enroll-webauthn-device.ts
+++ b/app/javascript/packages/webauthn/enroll-webauthn-device.ts
@@ -15,6 +15,8 @@ interface AuthenticatorAttestationResponseBrowserSupport
getAuthenticatorData: AuthenticatorAttestationResponse['getAuthenticatorData'] | undefined;
}
+type PublicKeyCredentialHintType = 'client-device' | 'security-key' | 'hybrid';
+
interface EnrollOptions {
user: PublicKeyCredentialUserEntity;
@@ -23,6 +25,8 @@ interface EnrollOptions {
excludeCredentials: PublicKeyCredentialDescriptor[];
authenticatorAttachment?: AuthenticatorAttachment;
+
+ hints?: Array;
}
interface EnrollResult {
@@ -37,6 +41,10 @@ interface EnrollResult {
transports?: string[];
}
+interface AuthenticatorSelectionCriteriaWithHints extends AuthenticatorSelectionCriteria {
+ hints?: Array;
+}
+
/**
* All possible algorithms supported within the CBOR Object Signing and Encryption (COSE) format.
*
@@ -76,6 +84,7 @@ async function enrollWebauthnDevice({
challenge,
excludeCredentials,
authenticatorAttachment,
+ hints,
}: EnrollOptions): Promise {
const credential = (await navigator.credentials.create({
publicKey: {
@@ -89,7 +98,8 @@ async function enrollWebauthnDevice({
// Prevents user from needing to use PIN with Security Key
userVerification: 'discouraged',
authenticatorAttachment,
- },
+ hints,
+ } as AuthenticatorSelectionCriteriaWithHints,
excludeCredentials,
},
})) as PublicKeyCredential;
diff --git a/app/javascript/packs/webauthn-setup.ts b/app/javascript/packs/webauthn-setup.ts
index 614335d8d23..743b99c827c 100644
--- a/app/javascript/packs/webauthn-setup.ts
+++ b/app/javascript/packs/webauthn-setup.ts
@@ -56,6 +56,7 @@ function webauthn() {
.filter(Boolean),
),
authenticatorAttachment: platformAuthenticator ? 'platform' : 'cross-platform',
+ hints: platformAuthenticator ? ['client-device', 'hybrid'] : ['security-key'],
})
.then((result) => {
(document.getElementById('webauthn_id') as HTMLInputElement).value = result.webauthnId;
diff --git a/app/models/profile.rb b/app/models/profile.rb
index c74ba71f725..3bf0af2240b 100644
--- a/app/models/profile.rb
+++ b/app/models/profile.rb
@@ -36,6 +36,7 @@ class Profile < ApplicationRecord
legacy_unsupervised: 1,
legacy_in_person: 2,
unsupervised_with_selfie: 3,
+ in_person: 4,
}
attr_reader :personal_key
@@ -110,6 +111,7 @@ def activate(reason_deactivated: nil)
def tmx_status
return nil unless IdentityConfig.store.in_person_proofing_enforce_tmx
+ return nil unless FeatureManagement.proofing_device_profiling_decisioning_enabled?
fraud_pending_reason || :threatmetrix_pass
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 46cfa4074df..65914ca1452 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -25,6 +25,8 @@ class User < ApplicationRecord
MAX_RECENT_EVENTS = 5
MAX_RECENT_DEVICES = 5
+ BIOMETRIC_COMPARISON_IDV_LEVELS = %w[unsupervised_with_selfie in_person].to_set.freeze
+
enum otp_delivery_preference: { sms: 0, voice: 1 }
# rubocop:disable Rails/HasManyOrHasOneDependent
@@ -76,6 +78,10 @@ def confirmed?
email_addresses.where.not(confirmed_at: nil).any?
end
+ def has_gov_or_mil_email?
+ confirmed_email_addresses.any?(&:gov_or_mil?)
+ end
+
def accepted_rules_of_use_still_valid?
if self.accepted_terms_at.present?
self.accepted_terms_at > IdentityConfig.store.rules_of_use_updated_at &&
@@ -361,7 +367,7 @@ def identity_verified?(service_provider: nil)
end
def identity_verified_with_selfie?
- active_profile&.idv_level == 'unsupervised_with_selfie'
+ BIOMETRIC_COMPARISON_IDV_LEVELS.include?(active_profile&.idv_level)
end
def reproof_for_irs?(service_provider:)
diff --git a/app/presenters/idv/address_presenter.rb b/app/presenters/idv/address_presenter.rb
index 26ca19b177d..b7c9bd17ac7 100644
--- a/app/presenters/idv/address_presenter.rb
+++ b/app/presenters/idv/address_presenter.rb
@@ -2,14 +2,6 @@
module Idv
class AddressPresenter
- def initialize(pii:)
- @pii = pii
- end
-
- def pii
- @pii
- end
-
def address_line1_hint
"#{I18n.t('forms.example')} 150 Calle A Apt 3"
end
diff --git a/app/presenters/piv_cac_recommended_presenter.rb b/app/presenters/piv_cac_recommended_presenter.rb
new file mode 100644
index 00000000000..882730987e4
--- /dev/null
+++ b/app/presenters/piv_cac_recommended_presenter.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class PivCacRecommendedPresenter
+ attr_reader :user
+ def initialize(user)
+ @user = user
+ end
+
+ def info
+ if MfaPolicy.new(user).two_factor_enabled?
+ I18n.t('two_factor_authentication.piv_cac_upsell.existing_user_info', email_type: email_type)
+ else
+ I18n.t('two_factor_authentication.piv_cac_upsell.new_user_info', email_type: email_type)
+ end
+ end
+
+ def email_type
+ address = user.confirmed_email_addresses.find { |address| address.gov_or_mil? }
+ case address.email.end_with?('.gov')
+ when true
+ '.gov'
+ else
+ '.mil'
+ end
+ end
+
+ def skip_text
+ if MfaPolicy.new(user).two_factor_enabled?
+ I18n.t('two_factor_authentication.piv_cac_upsell.skip')
+ else
+ I18n.t('two_factor_authentication.piv_cac_upsell.choose_other_method')
+ end
+ end
+end
diff --git a/app/presenters/two_factor_options_presenter.rb b/app/presenters/two_factor_options_presenter.rb
index 76e8c3b361a..83a0d4434f7 100644
--- a/app/presenters/two_factor_options_presenter.rb
+++ b/app/presenters/two_factor_options_presenter.rb
@@ -11,6 +11,7 @@ class TwoFactorOptionsPresenter
:user_agent
delegate :two_factor_enabled?, to: :mfa_policy
+ delegate :has_gov_or_mil_email?, to: :user, prefix: :user
def initialize(
user_agent:,
diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb
index 3d42c776102..a7761ae27be 100644
--- a/app/services/analytics_events.rb
+++ b/app/services/analytics_events.rb
@@ -806,6 +806,20 @@ def idv_barcode_warning_retake_photos_clicked(liveness_checking_required:, **ext
)
end
+ # @param [Hash] error
+ def idv_camera_info_error(error:, **_extra)
+ track_event(:idv_camera_info_error, error: error)
+ end
+
+ # @param [String] flow_path whether the user is in the hybrid or standard flow
+ # @param [Array] camera_info Information on the users cameras max resolution
+ # as captured by the browser
+ def idv_camera_info_logged(flow_path:, camera_info:, **_extra)
+ track_event(
+ :idv_camera_info_logged, flow_path: flow_path, camera_info: camera_info
+ )
+ end
+
# @param [String] step the step that the user was on when they clicked cancel
# @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# @param [String,nil] active_profile_idv_level ID verification level of user's active profile.
@@ -3162,6 +3176,34 @@ def idv_request_letter_visited(
)
end
+ # Acuant SDK errored after loading but before initialization
+ # @param [Boolean] success
+ # @param [String] error_message
+ # @param [Boolean] liveness_checking_required Whether or not the selfie is required
+ # @param [String] acuant_version
+ # @param [Integer] captureAttempts number of attempts to capture / upload an image
+ # (previously called "attempt")
+ # rubocop:disable Naming/VariableName,Naming/MethodParameterName
+ def idv_sdk_error_before_init(
+ success:,
+ error_message:,
+ liveness_checking_required:,
+ acuant_version:,
+ captureAttempts: nil,
+ **extra
+ )
+ track_event(
+ :idv_sdk_error_before_init,
+ success:,
+ error_message: error_message,
+ liveness_checking_required:,
+ acuant_version: acuant_version,
+ captureAttempts: captureAttempts,
+ **extra,
+ )
+ end
+ # rubocop:enable Naming/VariableName,Naming/MethodParameterName
+
# User closed the SDK for taking a selfie without submitting a photo
# @param [String] acuant_version
# @param [Integer] captureAttempts number of attempts to capture / upload an image
@@ -4401,6 +4443,21 @@ def piv_cac_login_visited
track_event(:piv_cac_login_visited)
end
+ # @param [String] action what action user made
+ # Tracks when user submits an action on Piv Cac recommended page
+ def piv_cac_recommended(action: nil, **extra)
+ track_event(
+ :piv_cac_recommended,
+ action: action,
+ **extra,
+ )
+ end
+
+ # Tracks when user visits piv cac recommended
+ def piv_cac_recommended_visited
+ track_event(:piv_cac_recommended_visited)
+ end
+
# @identity.idp.previous_event_name User Registration: piv cac setup visited
# @identity.idp.previous_event_name PIV CAC setup visited
# Tracks when user's piv cac setup
diff --git a/app/services/idv/profile_maker.rb b/app/services/idv/profile_maker.rb
index 982ea822513..42dd8a07b18 100644
--- a/app/services/idv/profile_maker.rb
+++ b/app/services/idv/profile_maker.rb
@@ -48,7 +48,12 @@ def save_profile(
def set_idv_level(in_person_verification_needed:, selfie_check_performed:)
if in_person_verification_needed
- :legacy_in_person
+ if IdentityConfig.store.in_person_proofing_enforce_tmx &&
+ FeatureManagement.proofing_device_profiling_decisioning_enabled?
+ :in_person
+ else
+ :legacy_in_person
+ end
elsif FeatureManagement.idv_allow_selfie_check? && selfie_check_performed
:unsupervised_with_selfie
else
diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb
index 93c464fa02e..dd89d55cb39 100644
--- a/app/services/idv/session.rb
+++ b/app/services/idv/session.rb
@@ -169,6 +169,16 @@ def failed_phone_step_numbers
session[:failed_phone_step_params] ||= []
end
+ def updated_user_address=(updated_user_address)
+ session[:updated_user_address] = nil if updated_user_address.nil?
+ session[:updated_user_address] = updated_user_address.to_h
+ end
+
+ def updated_user_address
+ return nil if session[:updated_user_address].blank?
+ Pii::Address.new(**session[:updated_user_address])
+ end
+
def add_failed_phone_step_number(phone)
parsed_phone = Phonelib.parse(phone)
phone_e164 = parsed_phone.e164
diff --git a/app/services/pii/address.rb b/app/services/pii/address.rb
new file mode 100644
index 00000000000..64517cb6d97
--- /dev/null
+++ b/app/services/pii/address.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# rubocop:disable Style/MutableConstant
+module Pii
+ Address = RedactedData.define(:state, :zipcode, :city, :address1, :address2)
+end
+# rubocop:enable Style/MutableConstant
diff --git a/app/views/idv/address/new.html.erb b/app/views/idv/address/new.html.erb
index 80358753a39..29a301e8552 100644
--- a/app/views/idv/address/new.html.erb
+++ b/app/views/idv/address/new.html.erb
@@ -21,7 +21,7 @@
%>
<%= simple_form_for(
- :idv_form,
+ @address_form,
url: idv_address_path,
method: 'POST',
html: { autocomplete: 'off', class: 'margin-top-5' },
@@ -35,7 +35,6 @@
hint_html: { class: @presenter.hint_class },
required: true,
maxlength: 255,
- input_html: { value: @presenter.pii['address1'] },
) %>
<%= render ValidatedFieldComponent.new(
form: f,
@@ -45,7 +44,6 @@
hint_html: { class: @presenter.hint_class },
required: false,
maxlength: 255,
- input_html: { value: @presenter.pii['address2'] },
) %>
<%= render ValidatedFieldComponent.new(
form: f,
@@ -55,7 +53,6 @@
hint_html: { class: @presenter.hint_class },
required: true,
maxlength: 255,
- input_html: { value: @presenter.pii['city'] },
) %>
<%= render ValidatedFieldComponent.new(
form: f,
@@ -63,7 +60,6 @@
collection: us_states_territories,
label: t('idv.form.state'),
required: true,
- selected: @presenter.pii['state'],
) %>