diff --git a/.gitignore b/.gitignore index 86cf95e..f4aee86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.iml +# Local runtime secrets for docker compose env.list /copy_cache/chrome_driver ibeam.egg-info @@ -6,4 +7,4 @@ ibeam.egg-info /copy_cache/clientportal.gw/root/webapps/demo/gateway.demo.js /dev __pycache__ -.idea \ No newline at end of file +.idea diff --git a/README.md b/README.md index e517411..fec505d 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ IBEAM_ACCOUNT=your_account123 IBEAM_PASSWORD=your_password123 ``` +For a reusable starting point, copy [`env.list.example`](./env.list.example) to `env.list` and adjust the values for your account. + Run the following command: ```posh @@ -125,6 +127,40 @@ curl -X GET "https://localhost:5000/v1/api/iserver/auth/status" -k Read more in [Installation and Startup][installation-and-startup] and [Advanced Secrets][advanced-secrets]. +### Two-factor device selection + +Some IBKR accounts show an extra `Select Second Factor Device` dropdown after submitting the username and password. If your account does, add the following settings to `env.list`: + +```posh +IBEAM_TWO_FA_SELECT=true +IBEAM_TWO_FA_SELECT_TARGET=IB Key +``` + +If your IBKR page uses a different device label, set `IBEAM_TWO_FA_SELECT_TARGET` to the text shown in that dropdown, for example `Mobile Authenticator App`. + +`IBEAM_TWO_FA_SELECT_EL_ID` defaults to `TAG@@select` and usually does not need to be changed. + +IBeam also includes a built-in fallback for code-entry screens that expose an input with a placeholder containing `Code`, which covers variants such as `Mobile Authenticator App Code` without requiring an extra env override. + +### Manual 2FA fallback + +If you do not have a usable automated 2FA handler, IBeam can pause at the second-factor step and wait for you to finish the login manually in your own browser. + +Add the following optional settings to `env.list`: + +```posh +IBEAM_MANUAL_TWO_FA=true +IBEAM_MANUAL_TWO_FA_TIMEOUT=300 +``` + +When enabled and no `IBEAM_TWO_FA_HANDLER` is configured, IBeam will: + +1. Drive the login flow up to the second-factor step. +1. Log instructions to open `https://localhost:5000` and complete the IBKR login manually. +1. Wait for the gateway session to become authenticated before continuing. + +This is intended for setups where the second factor is device-bound and cannot be retrieved programmatically, such as a mobile authenticator app. + ## How does IBeam work? In a standard startup IBeam performs the following: @@ -258,4 +294,4 @@ Thanks and have an awesome day 👋 [secret-manager-docs]: https://cloud.google.com/secret-manager/docs -[ibind]: https://github.com/Voyz/ibind \ No newline at end of file +[ibind]: https://github.com/Voyz/ibind diff --git a/env.list.example b/env.list.example new file mode 100644 index 0000000..a48609e --- /dev/null +++ b/env.list.example @@ -0,0 +1,14 @@ +IBEAM_ACCOUNT=your_account123 +IBEAM_PASSWORD=your_password123 + +# Enable this only if IBKR shows a "Select Second Factor Device" dropdown +IBEAM_TWO_FA_SELECT=false + +# Defaults shown for reference. Override only if IBKR changes the page structure. +IBEAM_TWO_FA_SELECT_EL_ID=TAG@@select +IBEAM_TWO_FA_SELECT_TARGET=IB Key + +# Optional manual fallback when no 2FA handler is configured. +# IBeam will wait for you to complete login manually in a normal browser. +IBEAM_MANUAL_TWO_FA=false +IBEAM_MANUAL_TWO_FA_TIMEOUT=300 diff --git a/ibeam/ibeam_starter.py b/ibeam/ibeam_starter.py index 72320f3..2a8e4e6 100644 --- a/ibeam/ibeam_starter.py +++ b/ibeam/ibeam_starter.py @@ -28,6 +28,25 @@ def add_to_path(): _LOGGER = logging.getLogger('ibeam') +_SENSITIVE_CONFIG_KEYS = { + 'ACCOUNT', + 'PASSWORD', + 'KEY', + 'SECRET', + 'TOKEN', +} + + +def redact_config(config: dict) -> dict: + redacted = {} + for key, value in config.items(): + if any(sensitive_key in key for sensitive_key in _SENSITIVE_CONFIG_KEYS): + redacted[key] = '***REDACTED***' if value is not None else None + else: + redacted[key] = value + + return redacted + def parse_args(): parser = argparse.ArgumentParser(description='Start, authenticate and verify the IB Gateway.') parser.add_argument('-a', '--authenticate', action='store_true', help='Authenticates the existing gateway.') @@ -100,10 +119,14 @@ def parse_args(): targets=targets, base_url=cnf.GATEWAY_BASE_URL, route_auth=cnf.ROUTE_AUTH, + route_tickle=cnf.ROUTE_TICKLE, two_fa_select_target=cnf.TWO_FA_SELECT_TARGET, strict_two_fa_code=cnf.STRICT_TWO_FA_CODE, + manual_two_fa=cnf.MANUAL_TWO_FA, + manual_two_fa_timeout=cnf.MANUAL_TWO_FA_TIMEOUT, max_immediate_attempts=cnf.MAX_IMMEDIATE_ATTEMPTS, oauth_timeout=cnf.OAUTH_TIMEOUT, + request_timeout=cnf.REQUEST_TIMEOUT, max_presubmit_buffer=cnf.MAX_PRESUBMIT_BUFFER, min_presubmit_buffer=cnf.MIN_PRESUBMIT_BUFFER, max_failed_auth=cnf.MAX_FAILED_AUTH, @@ -149,7 +172,7 @@ def stop(_, _1): signal.signal(signal.SIGINT, stop) signal.signal(signal.SIGTERM, stop) - _LOGGER.info(f'Configuration:\n{cnf.all_variables}') + _LOGGER.info(f'Configuration:\n{redact_config(cnf.all_variables)}') if args.start: pids = process_handler.start_gateway() @@ -190,4 +213,4 @@ def stop(_, _1): client.maintain() else: _LOGGER.info(f'IBeam initialised in an inactive state. Starting maintenance loop.') - client.maintain() \ No newline at end of file + client.maintain() diff --git a/ibeam/src/handlers/login_handler.py b/ibeam/src/handlers/login_handler.py index 38b6a24..d37f97d 100644 --- a/ibeam/src/handlers/login_handler.py +++ b/ibeam/src/handlers/login_handler.py @@ -1,8 +1,12 @@ +import json import logging +import ssl import time +import urllib.request from functools import partial from pathlib import Path from typing import Optional, cast +from urllib.error import HTTPError, URLError from cryptography.fernet import Fernet from selenium import webdriver @@ -113,10 +117,14 @@ def __init__(self, targets: Targets, base_url: str, route_auth: str, + route_tickle: str, two_fa_select_target: str, strict_two_fa_code: bool, + manual_two_fa: bool, + manual_two_fa_timeout: int, max_immediate_attempts: int, oauth_timeout: int, + request_timeout: int, max_presubmit_buffer: int, min_presubmit_buffer: int, max_failed_auth: int, @@ -132,10 +140,14 @@ def __init__(self, self.base_url = base_url self.route_auth = route_auth + self.route_tickle = route_tickle self.two_fa_select_target = two_fa_select_target self.strict_two_fa_code = strict_two_fa_code + self.manual_two_fa = manual_two_fa + self.manual_two_fa_timeout = manual_two_fa_timeout self.max_immediate_attempts = max_immediate_attempts self.oauth_timeout = oauth_timeout + self.request_timeout = request_timeout self.max_presubmit_buffer = max_presubmit_buffer self.min_presubmit_buffer = min_presubmit_buffer self.max_failed_auth = max_failed_auth @@ -146,6 +158,36 @@ def __init__(self, self.failed_attempts = 0 self.presubmit_buffer = self.min_presubmit_buffer + def _wait_for_manual_gateway_authentication(self) -> bool: + deadline = time.time() + self.manual_two_fa_timeout + ssl_context = ssl._create_unverified_context() + + while time.time() < deadline: + try: + response = urllib.request.urlopen( + urllib.request.Request(self.base_url + self.route_tickle, method='POST'), + context=ssl_context, + timeout=self.request_timeout, + ) + payload = json.loads(response.read().decode('utf8')) + auth_status = payload.get('iserver', {}).get('authStatus', {}) + if auth_status.get('authenticated'): + return True + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError): + pass + + time.sleep(5) + + return False + + def _find_two_fa_input_target(self, targets: Targets, driver: webdriver.Chrome) -> Target: + for target_name in ('TWO_FA_INPUT', 'TWO_FA_INPUT_GENERIC'): + target = targets[target_name] + if driver.find_elements(target.by, target.locator_identifier): + return target + + return targets['TWO_FA_INPUT'] + def step_login(self, targets: Targets, wait_and_identify_trigger: callable, @@ -198,10 +240,12 @@ def step_login(self, trigger, target = wait_and_identify_trigger( has_text(targets['SUCCESS']), is_visible(targets['TWO_FA']), - is_visible(targets['TWO_FA_SELECT']), + is_clickable(targets['TWO_FA_INPUT']), + is_clickable(targets['TWO_FA_INPUT_GENERIC']), is_visible(targets['TWO_FA_NOTIFICATION']), is_visible(targets['ERROR']), is_clickable(targets['IBKEY_PROMO']), + *( [is_visible(targets['TWO_FA_SELECT'])] if 'TWO_FA_SELECT' in targets else [] ), ) return trigger, target @@ -215,17 +259,43 @@ def step_select_two_fa(self, _LOGGER.info(f'Required to select a 2FA method.') select_el = find_element(targets['TWO_FA_SELECT'], driver) select = Select(select_el) - select.select_by_visible_text(two_fa_select_target) + option_texts = [option.text.strip() for option in select.options if option.text.strip()] + selectable_option_texts = [text for text in option_texts if text.lower() != 'select type'] + + _LOGGER.info(f'Available 2FA methods: {selectable_option_texts}') + + selected_text = None + + try: + select.select_by_visible_text(two_fa_select_target) + selected_text = two_fa_select_target + except Exception: + for option_text in selectable_option_texts: + if two_fa_select_target.lower() in option_text.lower(): + select.select_by_visible_text(option_text) + selected_text = option_text + break + + if selected_text is None and len(selectable_option_texts) == 1: + selected_text = selectable_option_texts[0] + select.select_by_visible_text(selected_text) + + if selected_text is None: + raise RuntimeError( + f'Unable to select 2FA method "{two_fa_select_target}". Available methods: {selectable_option_texts}' + ) trigger, target = wait_and_identify_trigger( has_text(targets['SUCCESS']), is_visible(targets['TWO_FA']), + is_clickable(targets['TWO_FA_INPUT']), + is_clickable(targets['TWO_FA_INPUT_GENERIC']), is_visible(targets['TWO_FA_NOTIFICATION']), is_visible(targets['ERROR']), is_clickable(targets['IBKEY_PROMO']) ) - _LOGGER.info(f'2FA method "{two_fa_select_target}" selected successfully.') + _LOGGER.info(f'2FA method "{selected_text}" selected successfully.') return trigger, target @@ -261,6 +331,22 @@ def step_two_fa(self, ): _LOGGER.info(f'Credentials correct, but Gateway requires two-factor authentication.') if two_fa_handler is None: + if self.manual_two_fa: + _LOGGER.warning( + f'######## ATTENTION! ######## No 2FA handler found. Waiting up to {self.manual_two_fa_timeout} seconds for manual 2FA completion.' + ) + _LOGGER.warning( + f'Open {self.base_url} in your browser, complete the IBKR login manually, and keep IBeam running while it waits for the gateway session to become authenticated.' + ) + save_screenshot(driver, self.outputs_dir, '__manual-two-fa') + + if self._wait_for_manual_gateway_authentication(): + _LOGGER.info('Gateway authenticated after manual 2FA completion.') + self.step_success() + + _LOGGER.error('Manual 2FA timeout reached before the gateway session became authenticated.') + raise AttemptException(cause='break') + _LOGGER.critical( f'######## ATTENTION! ######## No 2FA handler found. You may define your own 2FA handler or use built-in handlers. See documentation for more: https://github.com/Voyz/ibeam/wiki/Two-Factor-Authentication') raise AttemptException(cause='shutdown') @@ -271,7 +357,8 @@ def step_two_fa(self, _LOGGER.warning(f'No 2FA code returned. Aborting authentication.') raise AttemptException(cause='break') else: - two_fa_el, _ = wait_and_identify_trigger(is_clickable(targets['TWO_FA_INPUT']), skip_identify=True) + two_fa_input_target = self._find_two_fa_input_target(targets, driver) + two_fa_el, _ = wait_and_identify_trigger(is_clickable(two_fa_input_target), skip_identify=True) two_fa_el.clear() two_fa_el.send_keys(two_fa_code) @@ -324,10 +411,12 @@ def step_paper_toggle(self, trigger, target = wait_and_identify_trigger( has_text(targets['SUCCESS']), is_visible(targets['TWO_FA']), - is_visible(targets['TWO_FA_SELECT']), + is_clickable(targets['TWO_FA_INPUT']), + is_clickable(targets['TWO_FA_INPUT_GENERIC']), is_visible(targets['TWO_FA_NOTIFICATION']), is_visible(targets['ERROR']), is_clickable(targets['IBKEY_PROMO']), + *( [is_visible(targets['TWO_FA_SELECT'])] if 'TWO_FA_SELECT' in targets else [] ), ) return trigger, target @@ -418,13 +507,13 @@ def attempt( if target == targets['ERROR'] and trigger.text == 'You have selected the Live Account Mode, but the specified user is a Paper Trading user. Please select the correct Login mode.': trigger, target = self.step_paper_toggle(driver, targets, wait_and_identify_trigger) - if target == targets['TWO_FA_SELECT']: + if 'TWO_FA_SELECT' in targets and target == targets['TWO_FA_SELECT']: trigger, target = self.step_select_two_fa(targets, wait_and_identify_trigger, driver, self.two_fa_select_target) if target == targets['TWO_FA_NOTIFICATION']: trigger, target = self.step_two_fa_notification(targets, wait_and_identify_trigger, driver, self.two_fa_handler) - if target == targets['TWO_FA']: + if target == targets['TWO_FA'] or target == targets['TWO_FA_INPUT'] or target == targets['TWO_FA_INPUT_GENERIC']: trigger, target = self.step_two_fa(targets, wait_and_identify_trigger, driver, self.two_fa_handler, self.strict_two_fa_code) if target == targets['IBKEY_PROMO']: @@ -433,7 +522,7 @@ def attempt( if target == targets['ERROR']: self.step_error(driver, trigger, self.max_presubmit_buffer, self.max_failed_auth, self.outputs_dir) - elif target == targets['TWO_FA']: + elif target == targets['TWO_FA'] or target == targets['TWO_FA_INPUT'] or target == targets['TWO_FA_INPUT_GENERIC']: self.step_failed_two_fa(driver) elif target == targets['SUCCESS']: @@ -525,4 +614,4 @@ def login(self) -> (bool, bool): finally: shut_down_browser(driver, display) - return success, False \ No newline at end of file + return success, False diff --git a/ibeam/src/login/targets.py b/ibeam/src/login/targets.py index 2564999..120919a 100644 --- a/ibeam/src/login/targets.py +++ b/ibeam/src/login/targets.py @@ -22,6 +22,7 @@ def __init__( self.type = type self.identifier = identifier self.variable = variable + self.locator_identifier = identifier if type == 'ID': self.by = By.ID @@ -32,6 +33,13 @@ def __init__( elif type == 'CLASS_NAME': self.by = By.CLASS_NAME self._identify = self.identify_by_class + elif type == 'PLACEHOLDER': + self.by = By.CSS_SELECTOR + self.locator_identifier = f'input[placeholder*="{identifier}"]' + self._identify = self.identify_by_placeholder + elif type == 'TAG': + self.by = By.TAG_NAME + self._identify = self.identify_by_tag elif type == 'NAME': self.by = By.NAME self._identify = self.identify_by_name @@ -59,6 +67,12 @@ def identify_by_class(self, trigger: WebElement) -> bool: def identify_by_name(self, trigger: WebElement) -> bool: return self.identifier in trigger.get_attribute('name') + def identify_by_tag(self, trigger: WebElement) -> bool: + return self.identifier == trigger.tag_name + + def identify_by_placeholder(self, trigger: WebElement) -> bool: + return self.identifier in trigger.get_attribute('placeholder') + def identify_by_text(self, trigger: WebElement) -> bool: return self.identifier in trigger.text @@ -99,7 +113,9 @@ def create_targets(cnf: Config) -> Targets: targets['TWO_FA'] = Target(cnf.TWO_FA_EL_ID) targets['TWO_FA_NOTIFICATION'] = Target(cnf.TWO_FA_NOTIFICATION_EL) targets['TWO_FA_INPUT'] = Target(cnf.TWO_FA_INPUT_EL_ID) - targets['TWO_FA_SELECT'] = Target(cnf.TWO_FA_SELECT_EL_ID) + targets['TWO_FA_INPUT_GENERIC'] = Target('PLACEHOLDER@@Code') + if cnf.TWO_FA_SELECT: + targets['TWO_FA_SELECT'] = Target(cnf.TWO_FA_SELECT_EL_ID) targets['LIVE_PAPER_TOGGLE'] = Target(cnf.LIVE_PAPER_TOGGLE_EL) return targets @@ -120,20 +136,20 @@ def identify_target(trigger: WebElement, targets: Targets) -> Optional[Target]: def is_present(target: Target) -> callable: - return EC.presence_of_element_located((target.by, target.identifier)) + return EC.presence_of_element_located((target.by, target.locator_identifier)) def is_visible(target: Target) -> callable: - return EC.visibility_of_element_located((target.by, target.identifier)) + return EC.visibility_of_element_located((target.by, target.locator_identifier)) def is_clickable(target: Target) -> callable: - return EC.element_to_be_clickable((target.by, target.identifier)) + return EC.element_to_be_clickable((target.by, target.locator_identifier)) def has_text(target: Target) -> callable: - return text_to_be_present_in_element(target.by, target.identifier) + return text_to_be_present_in_element(target.by, target.locator_identifier) def find_element(target: Target, driver: webdriver.Chrome) -> WebElement: - return driver.find_element(target.by, target.identifier) + return driver.find_element(target.by, target.locator_identifier) diff --git a/ibeam/src/var.py b/ibeam/src/var.py index 462a854..56ed280 100644 --- a/ibeam/src/var.py +++ b/ibeam/src/var.py @@ -9,6 +9,17 @@ def to_bool(value): return bool(strtobool(str(value))) +def strip_quotes(value): + if value is None: + return value + + value = str(value).strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + return value[1:-1] + + return value + + INPUTS_DIR = os.environ.get('IBEAM_INPUTS_DIR', '/srv/inputs/') """Directory path of Inputs Directory.""" @@ -176,13 +187,22 @@ def to_bool(value): STRICT_TWO_FA_CODE = to_bool(os.environ.get('IBEAM_STRICT_TWO_FA_CODE', True)) """Whether to ensure only 2FA code made of 6 digits can be used.""" -TWO_FA_SELECT_EL_ID = os.environ.get('IBEAM_TWO_FA_SELECT_EL_ID', 'ID@@xyz-field-bronze-response') +MANUAL_TWO_FA = to_bool(os.environ.get('IBEAM_MANUAL_TWO_FA', False)) +"""Whether to wait for a user to finish 2FA manually when no handler is configured.""" + +MANUAL_TWO_FA_TIMEOUT = int(os.environ.get('IBEAM_MANUAL_TWO_FA_TIMEOUT', 300)) +"""How many seconds to wait for manual 2FA completion.""" + +TWO_FA_SELECT = to_bool(os.environ.get('IBEAM_TWO_FA_SELECT', False)) +"""Whether Gateway requires selecting a 2FA method from a dropdown before continuing.""" + +TWO_FA_SELECT_EL_ID = strip_quotes(os.environ.get('IBEAM_TWO_FA_SELECT_EL_ID', 'TAG@@select')) """HTML element check for if Gateway requires to select the 2FA method.""" -TWO_FA_SELECT_TARGET = os.environ.get('IBEAM_TWO_FA_SELECT_TARGET', 'IB Key') +TWO_FA_SELECT_TARGET = strip_quotes(os.environ.get('IBEAM_TWO_FA_SELECT_TARGET', 'IB Key')) """Option that is to be chosen in the 2FA select dropdown""" CUSTOM_TWO_FA_HANDLER = os.environ.get('IBEAM_CUSTOM_TWO_FA_HANDLER', 'custom_two_fa_handler.CustomTwoFaHandler') """Fully qualified path of the custom 2FA handler in the inputs directory.""" -all_variables = {key: value for key, value in vars().items() if (not key.startswith("__") and key.isupper() and key != 'UNDEFINED')} \ No newline at end of file +all_variables = {key: value for key, value in vars().items() if (not key.startswith("__") and key.isupper() and key != 'UNDEFINED')} diff --git a/tests/ibeam/src/login/test_targets.py b/tests/ibeam/src/login/test_targets.py new file mode 100644 index 0000000..ca83b48 --- /dev/null +++ b/tests/ibeam/src/login/test_targets.py @@ -0,0 +1,85 @@ +from types import SimpleNamespace + +from ibeam.config import Config +from ibeam.src.login.targets import Target, create_targets +from ibeam.src.var import strip_quotes + + +def test_target_identify_by_tag(): + target = Target('TAG@@select') + trigger = SimpleNamespace(tag_name='select') + + assert target.identify(trigger) is True + + +def test_target_identify_by_placeholder(): + target = Target('PLACEHOLDER@@Code') + trigger = SimpleNamespace() + trigger.get_attribute = lambda attr: 'Mobile Authenticator App Code' if attr == 'placeholder' else None + + assert target.identify(trigger) is True + + +def test_create_targets_uses_select_tag_for_two_fa_select_by_default(): + config = Config({ + 'PASSWORD_EL': 'NAME@@password', + 'SUBMIT_EL': 'CSS_SELECTOR@@.btn.btn-lg.btn-primary', + 'SUCCESS_EL_TEXT': 'TAG_NAME@@Client login succeeds', + 'IBKEY_PROMO_EL_CLASS': 'CLASS_NAME@@ibkey-promo-skip', + 'TWO_FA_EL_ID': 'ID@@twofactbase', + 'TWO_FA_NOTIFICATION_EL': 'CLASS_NAME@@login-step-notification', + 'TWO_FA_INPUT_EL_ID': 'ID@@xyz-field-bronze-response', + 'TWO_FA_SELECT': True, + 'TWO_FA_SELECT_EL_ID': 'TAG@@select', + 'LIVE_PAPER_TOGGLE_EL': 'FOR@@label[for=toggle1]', + }) + + targets = create_targets(config) + + assert targets['TWO_FA_SELECT'].by == 'tag name' + assert targets['TWO_FA_SELECT'].identifier == 'select' + + +def test_create_targets_skips_two_fa_select_when_disabled(): + config = Config({ + 'PASSWORD_EL': 'NAME@@password', + 'SUBMIT_EL': 'CSS_SELECTOR@@.btn.btn-lg.btn-primary', + 'SUCCESS_EL_TEXT': 'TAG_NAME@@Client login succeeds', + 'IBKEY_PROMO_EL_CLASS': 'CLASS_NAME@@ibkey-promo-skip', + 'TWO_FA_EL_ID': 'ID@@twofactbase', + 'TWO_FA_NOTIFICATION_EL': 'CLASS_NAME@@login-step-notification', + 'TWO_FA_INPUT_EL_ID': 'ID@@xyz-field-bronze-response', + 'TWO_FA_SELECT': False, + 'TWO_FA_SELECT_EL_ID': 'TAG@@select', + 'LIVE_PAPER_TOGGLE_EL': 'FOR@@label[for=toggle1]', + }) + + targets = create_targets(config) + + assert 'TWO_FA_SELECT' not in targets + + +def test_strip_quotes_removes_matching_wrappers(): + assert strip_quotes("'TAG@@select'") == 'TAG@@select' + assert strip_quotes('"Mobile Authenticator App"') == 'Mobile Authenticator App' + assert strip_quotes('IB Key') == 'IB Key' + + +def test_create_targets_includes_two_fa_input_target(): + config = Config({ + 'PASSWORD_EL': 'NAME@@password', + 'SUBMIT_EL': 'CSS_SELECTOR@@.btn.btn-lg.btn-primary', + 'SUCCESS_EL_TEXT': 'TAG_NAME@@Client login succeeds', + 'IBKEY_PROMO_EL_CLASS': 'CLASS_NAME@@ibkey-promo-skip', + 'TWO_FA_EL_ID': 'ID@@twofactbase', + 'TWO_FA_NOTIFICATION_EL': 'CLASS_NAME@@login-step-notification', + 'TWO_FA_INPUT_EL_ID': 'ID@@xyz-field-bronze-response', + 'TWO_FA_SELECT': True, + 'TWO_FA_SELECT_EL_ID': 'TAG@@select', + 'LIVE_PAPER_TOGGLE_EL': 'FOR@@label[for=toggle1]', + }) + + targets = create_targets(config) + + assert targets['TWO_FA_INPUT'].identifier == 'xyz-field-bronze-response' + assert targets['TWO_FA_INPUT_GENERIC'].locator_identifier == 'input[placeholder*="Code"]'