Skip to content

Commit d99384b

Browse files
committed
PIN Code Support
1 parent 438de97 commit d99384b

File tree

4 files changed

+210
-151
lines changed

4 files changed

+210
-151
lines changed

ark_sdk_python/auth/identity/ark_identity.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,11 @@ def __init__(
9292
) -> None:
9393
self.__username = username
9494
self.__password = password
95+
self.__logger = logger or get_logger(app=self.__class__.__name__)
9596
self.__identity_url = self.__resolve_fqdn_from_username_or_subdomain(identity_url, identity_tenant_subdomain)
9697
if not self.__identity_url.startswith('https://'):
9798
self.__identity_url = f'https://{self.__identity_url}'
9899
self.__mfa_type = mfa_type or 'email'
99-
self.__logger = logger or get_logger(app=self.__class__.__name__)
100100
self.__interaction_process: Optional[Process] = None
101101
self.__is_polling: bool = False
102102
self.__keyring = ArkKeyring(self.__class__.__name__.lower()) if cache_authentication else None
@@ -204,17 +204,20 @@ def __start_authentication(self) -> StartAuthResponse:
204204
return parsed_res
205205

206206
def __advance_authentication(
207-
self, mechanism_id: str, session_id: str, answer: str, action: str
208-
) -> Union[AdvanceAuthMidResponse, AdvanceAuthResponse]:
207+
self, mechanism_id: str, session_id: str, answer: str, action: str, is_idp_auth: bool = False
208+
) -> Union[AdvanceAuthMidResponse, AdvanceAuthResponse, IdpAuthStatusResponse]:
209209
self.__logger.info(f'Advancing authentication with user {self.__username} and fqdn {self.__identity_url} and action {action}')
210210
response = self.__session.post(
211211
url=f'{self.__identity_url}/Security/AdvanceAuthentication',
212212
json={'SessionId': session_id, 'MechanismId': mechanism_id, 'Action': action, 'Answer': answer},
213213
)
214214
try:
215-
parsed_res: AdvanceAuthMidResponse = AdvanceAuthMidResponse.model_validate_json(response.text)
216-
if parsed_res.result.summary == 'LoginSuccess':
217-
parsed_res: AdvanceAuthResponse = AdvanceAuthResponse.model_validate_json(response.text)
215+
if is_idp_auth:
216+
parsed_res: IdpAuthStatusResponse = IdpAuthStatusResponse.model_validate_json(response.text)
217+
else:
218+
parsed_res: AdvanceAuthMidResponse = AdvanceAuthMidResponse.model_validate_json(response.text)
219+
if parsed_res.result.summary == 'LoginSuccess':
220+
parsed_res: AdvanceAuthResponse = AdvanceAuthResponse.model_validate_json(response.text)
218221
except (ValidationError, TypeError) as ex:
219222
raise ArkException(f'Identity advance authentication failed to be parsed / validated [{response.text}]') from ex
220223
return parsed_res
@@ -281,14 +284,16 @@ def __poll_authentication(
281284
if output_conn.poll():
282285
mfa_code = output_conn.recv()
283286
advance_resp = self.__advance_authentication(
284-
mechanism.mechanism_id, start_auth_response.result.session_id, mfa_code, 'Answer'
287+
mechanism.mechanism_id, start_auth_response.result.session_id, mfa_code, 'Answer', False
285288
)
286289
if isinstance(advance_resp, AdvanceAuthResponse):
287290
input_conn.send('DONE')
288291
else:
289292
input_conn.send('CONTINUE')
290293
else:
291-
advance_resp = self.__advance_authentication(mechanism.mechanism_id, start_auth_response.result.session_id, '', 'Poll')
294+
advance_resp = self.__advance_authentication(
295+
mechanism.mechanism_id, start_auth_response.result.session_id, '', 'Poll', False
296+
)
292297
if isinstance(advance_resp, AdvanceAuthResponse):
293298
# Done here, save the token
294299
self.__is_polling = False
@@ -340,6 +345,37 @@ def __pick_mechanism(self, challenge: Challenge) -> Mechanism:
340345
self.__mfa_type = next(filter(lambda f: factors[f] == answers['mfa'], factors.keys()))
341346
return next(filter(lambda m: factors[m.name.lower()] == answers['mfa'], supported_mechanisms))
342347

348+
def __perform_pin_code_idp_authentication(
349+
self, start_auth_response: StartAuthResponse, profile: Optional[ArkProfile] = None, interactive: bool = False
350+
) -> None:
351+
if not interactive:
352+
raise ArkException('Non-interactive mode is not supported for OOB PIN code authentication')
353+
answers = inquirer.prompt(
354+
[inquirer.Password('answer', message='Please enter the PIN code displayed after you logged in to your identity provider')],
355+
render=ArkInquirerRender(),
356+
)
357+
if not answers:
358+
raise ArkAuthException('Canceled by user')
359+
pin_code = answers['answer']
360+
result = self.__advance_authentication('OOBAUTHPIN', start_auth_response.result.idp_login_session_id, pin_code, 'Answer', True)
361+
if (
362+
not result
363+
or not result.success
364+
or not isinstance(result, IdpAuthStatusResponse)
365+
or not result.result.summary
366+
or result.result.summary != 'LoginSuccess'
367+
or not result.result.token
368+
):
369+
raise ArkAuthException('Failed to perform idp authentication with OOB PIN')
370+
# We managed to successfully authenticate
371+
# Done here, save the token
372+
self.__session_details = result.result
373+
self.__session.headers.update({'Authorization': f'Bearer {result.result.token}', **ArkIdentityFQDNResolver.default_headers()})
374+
delta = self.__session_details.token_lifetime or DEFAULT_TOKEN_LIFETIME_SECONDS
375+
self.__session_exp = datetime.now() + timedelta(seconds=delta)
376+
if self.__cache_authentication:
377+
self.__save_cache(profile)
378+
343379
def __perform_idp_authentication(
344380
self, start_auth_response: StartAuthResponse, profile: Optional[ArkProfile] = None, interactive: bool = False
345381
) -> None:
@@ -356,6 +392,11 @@ def __perform_idp_authentication(
356392
# Error can be ignored
357393
webbrowser.open(start_auth_response.result.idp_redirect_short_url, new=0, autoraise=True)
358394

395+
# Pin code flow
396+
if start_auth_response.result.idp_oob_auth_pin_required:
397+
self.__perform_pin_code_idp_authentication(start_auth_response, profile, interactive)
398+
return
399+
359400
# Start polling for idp auth
360401
self.__is_polling = True
361402
start_time = datetime.now()
@@ -400,7 +441,7 @@ def __perform_up_authentication(
400441
raise ArkAuthException('Canceled by user')
401442
self.__password = answers['answer']
402443
advance_resp = self.__advance_authentication(
403-
mechanism.mechanism_id, start_auth_response.result.session_id, self.__password, 'Answer'
444+
mechanism.mechanism_id, start_auth_response.result.session_id, self.__password, 'Answer', False
404445
)
405446
if isinstance(advance_resp, AdvanceAuthResponse) and len(start_auth_response.result.challenges) == 1:
406447
# Done here, save the token
@@ -568,7 +609,7 @@ def auth_identity(self, profile: Optional[ArkProfile] = None, interactive: bool
568609
return
569610
else:
570611
oob_advance_resp = self.__advance_authentication(
571-
mechanism.mechanism_id, start_auth_response.result.session_id, '', 'StartOOB'
612+
mechanism.mechanism_id, start_auth_response.result.session_id, '', 'StartOOB', False
572613
)
573614
self.__poll_authentication(profile, mechanism, start_auth_response, oob_advance_resp, interactive)
574615
if self.__session_details:
@@ -591,7 +632,7 @@ def auth_identity(self, profile: Optional[ArkProfile] = None, interactive: bool
591632
for mechanism in start_auth_response.result.challenges[current_challenge_idx].mechanisms:
592633
if mechanism.name.lower() == self.__mfa_type.lower():
593634
oob_advance_resp = self.__advance_authentication(
594-
mechanism.mechanism_id, start_auth_response.result.session_id, '', 'StartOOB'
635+
mechanism.mechanism_id, start_auth_response.result.session_id, '', 'StartOOB', False
595636
)
596637
self.__poll_authentication(profile, mechanism, start_auth_response, oob_advance_resp, interactive)
597638
return
@@ -602,7 +643,9 @@ def auth_identity(self, profile: Optional[ArkProfile] = None, interactive: bool
602643
# Handle the rest of the challenges, might also handle the first challenge if no password is in the mechanisms
603644
for challenge in start_auth_response.result.challenges[current_challenge_idx:]:
604645
mechanism = self.__pick_mechanism(challenge)
605-
oob_advance_resp = self.__advance_authentication(mechanism.mechanism_id, start_auth_response.result.session_id, '', 'StartOOB')
646+
oob_advance_resp = self.__advance_authentication(
647+
mechanism.mechanism_id, start_auth_response.result.session_id, '', 'StartOOB', False
648+
)
606649
self.__poll_authentication(profile, mechanism, start_auth_response, oob_advance_resp, interactive)
607650

608651
# pylint: disable=unused-argument

ark_sdk_python/models/common/identity/ark_identity_auth_schemas.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,28 @@ class StartAuthResult(ArkModel):
4949
idp_login_session_id: Optional[str] = Field(default=None, alias='IdpLoginSessionId')
5050
idp_redirect_short_url: Optional[str] = Field(default=None, alias='IdpRedirectShortUrl')
5151
idp_short_url_id: Optional[str] = Field(default=None, alias='IdpShortUrlId')
52+
idp_oob_auth_pin_required: Optional[bool] = Field(default=None, alias='IdpOobAuthPinRequired')
5253
tenant_id: Optional[str] = Field(default=None, alias='TenantId')
5354

5455

5556
class IdpAuthStatusResult(ArkModel):
56-
state: str = Field(alias='State')
57+
auth_level: Optional[str] = Field(default=None, alias='AuthLevel')
58+
display_name: Optional[str] = Field(default=None, alias='DisplayName')
59+
auth: Optional[str] = Field(default=None, alias='Auth')
60+
user_id: Optional[str] = Field(default=None, alias='UserId')
61+
state: Optional[str] = Field(default=None, alias='State')
5762
token_lifetime: Optional[int] = Field(default=None, alias='TokenLifetime')
5863
token: Optional[str] = Field(default=None, alias='Token')
5964
refresh_token: Optional[str] = Field(default=None, alias='RefreshToken')
65+
email_address: Optional[str] = Field(default=None, alias='EmailAddress')
66+
user_directory: Optional[str] = Field(default=None, alias='UserDirectory')
67+
pod_fqdn: Optional[str] = Field(default=None, alias='PodFqdn')
68+
user: Optional[str] = Field(default=None, alias='User')
69+
customer_id: Optional[str] = Field(default=None, alias='CustomerID')
70+
forest: Optional[str] = Field(default=None, alias='Forest')
71+
system_id: Optional[str] = Field(default=None, alias='SystemID')
72+
source_ds_type: Optional[str] = Field(default=None, alias='SourceDsType')
73+
summary: Optional[str] = Field(default=None, alias='Summary')
6074

6175

6276
class TenantFqdnResponse(IdentityApiResponse):

0 commit comments

Comments
 (0)