From 82a26cd28774e382493b8aa372bd88679dc15964 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 10 Apr 2026 12:23:32 +0200 Subject: [PATCH 1/8] add failing tests for OOB IdP PIN code flow covers the PIN-required variant of OOB IdP authentication: prompts the user for the PIN shown in the browser after IdP login, posts it to AdvanceAuthentication with the OOBAUTHPIN mechanism and IdpLoginSessionId. adds regression tests for the existing polling (non-PIN) path. --- Tests/New-IDSession.Tests.ps1 | 126 ++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/Tests/New-IDSession.Tests.ps1 b/Tests/New-IDSession.Tests.ps1 index de6d68b..7241417 100644 --- a/Tests/New-IDSession.Tests.ps1 +++ b/Tests/New-IDSession.Tests.ps1 @@ -222,6 +222,132 @@ Describe $($PSCommandPath -Replace '.Tests.ps1') { } + Context 'OOB IdP Authentication' { + + BeforeEach { + + Mock Start-Process -MockWith {} + + #Downstream switch expects a WebSession on successful auth + Mock Invoke-IDRestMethod -MockWith { + $ISPSSSession.WebSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession + [pscustomobject]@{ + Summary = 'LoginSuccess' + Token = 'SomeToken' + State = 'Success' + } + } + + } + + Context 'PIN required' { + + BeforeEach { + + Mock Start-Authentication -MockWith { + [pscustomobject]@{ + TenantId = 'SomeID' + SessionId = 'SomeSession' + IdpRedirectShortUrl = 'https://short.example/x' + IdpLoginSessionId = 'IDP-123' + IdpOobAuthPinRequired = $true + } + } + + Mock Read-Host -MockWith { + ConvertTo-SecureString 'TEST-PIN' -AsPlainText -Force + } + + } + + It 'launches the browser to the IdpRedirectShortUrl' { + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Start-Process -Times 1 -Exactly -Scope It -ParameterFilter { + $args[0] -eq 'https://short.example/x' -or $FilePath -eq 'https://short.example/x' + } + } + + It 'prompts for the PIN as a secure string' { + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Read-Host -Times 1 -Exactly -Scope It -ParameterFilter { + $AsSecureString -eq $true + } + } + + It 'sends the PIN to AdvanceAuthentication with the OOBAUTHPIN mechanism' { + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Invoke-IDRestMethod -Times 1 -Exactly -Scope It -ParameterFilter { + $Uri -eq 'https://somedomain.id.cyberark.cloud/Security/AdvanceAuthentication' -and + $Method -eq 'POST' -and + ($Body | ConvertFrom-Json).MechanismId -eq 'OOBAUTHPIN' -and + ($Body | ConvertFrom-Json).Action -eq 'Answer' -and + ($Body | ConvertFrom-Json).Answer -eq 'TEST-PIN' + } + } + + It 'sends the IdpLoginSessionId as the SessionId in the PIN request' { + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Invoke-IDRestMethod -Times 1 -Exactly -Scope It -ParameterFilter { + $Uri -match 'AdvanceAuthentication$' -and + ($Body | ConvertFrom-Json).SessionId -eq 'IDP-123' + } + } + + It 'does not call OobAuthStatus when a PIN is required' { + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Invoke-IDRestMethod -Times 0 -Exactly -Scope It -ParameterFilter { + $Uri -match 'OobAuthStatus$' + } + } + + It 'does not invoke Start-AdvanceAuthentication (MFA challenge loop is bypassed)' { + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Start-AdvanceAuthentication -Times 0 -Exactly -Scope It + } + + } + + Context 'Polling (no PIN required)' { + + BeforeEach { + + Mock Start-Authentication -MockWith { + [pscustomobject]@{ + TenantId = 'SomeID' + SessionId = 'SomeSession' + IdpRedirectShortUrl = 'https://short.example/x' + IdpLoginSessionId = 'IDP-123' + } + } + + } + + It 'polls OobAuthStatus with the IdpLoginSessionId' { + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Invoke-IDRestMethod -Times 1 -Exactly -Scope It -ParameterFilter { + $Uri -eq 'https://somedomain.id.cyberark.cloud/Security/OobAuthStatus' -and + $Method -eq 'POST' -and + ($Body | ConvertFrom-Json).SessionId -eq 'IDP-123' + } + } + + It 'does not call AdvanceAuthentication when no PIN is required' { + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Invoke-IDRestMethod -Times 0 -Exactly -Scope It -ParameterFilter { + $Uri -match 'AdvanceAuthentication$' + } + } + + It 'does not prompt the user for a PIN' { + Mock Read-Host -MockWith { throw 'Read-Host should not be called in the polling path' } + { New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds } | + Should -Not -Throw + } + + } + + } + Context 'Output' { BeforeEach { From b8150b2e9487211b20e5ac412117a4ab8b6e91f0 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 10 Apr 2026 12:29:26 +0200 Subject: [PATCH 2/8] add support for OOB IdP authentication with PIN code some tenants are configured to display a PIN code in the browser after external IdP login. New-IDSession now detects the IdpOobAuthPinRequired flag on the Start-Authentication response, prompts the user for the PIN, and completes the session via AdvanceAuthentication using the OOBAUTHPIN mechanism and IdpLoginSessionId. previous tenants hung forever in the OobAuthStatus polling loop with no way to enter the PIN. reference: ark-sdk-python __perform_pin_code_idp_authentication --- IdentityCommand/Public/New-IDSession.ps1 | 46 ++++++++++++++++++------ 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/IdentityCommand/Public/New-IDSession.ps1 b/IdentityCommand/Public/New-IDSession.ps1 index d02ae85..74ceae5 100644 --- a/IdentityCommand/Public/New-IDSession.ps1 +++ b/IdentityCommand/Public/New-IDSession.ps1 @@ -96,20 +96,44 @@ $($IDSession.IdpRedirectShortUrl) #Launches the user's default browser and navigates it to the external identity provider Start-Process $IDSession.IdpRedirectShortUrl - $OobAuthStatusRequest = @{ } - $OobAuthStatusRequest['Method'] = 'POST' - #Undocumented endpoint for checking the IdpLoginSessionId's status. Sniffed out from the ark-sdk-python project - $OobAuthStatusRequest['Uri'] = "$tenant_url/Security/OobAuthStatus" - #We need the cookies the server provides in the same response it provides the IdpAuth information - $OobAuthStatusRequest['WebSession'] = $ISPSSSession.WebSession - $OobAuthStatusRequest['Body'] = @{SessionId = $IDSession.IdpLoginSessionId} | ConvertTo-Json + if ($IDSession.IdpOobAuthPinRequired) { + + #PIN-confirmation variant of OOB IdP auth. + #A PIN code is displayed in the browser after IdP login and must be entered here to complete authentication. + #Reference: ark-sdk-python __perform_pin_code_idp_authentication + $Pin = Read-Host -Prompt 'Enter the PIN code displayed after you logged in to your identity provider' -AsSecureString + + $OobPinAuthRequest = @{ } + $OobPinAuthRequest['Method'] = 'POST' + $OobPinAuthRequest['Uri'] = "$tenant_url/Security/AdvanceAuthentication" + $OobPinAuthRequest['WebSession'] = $ISPSSSession.WebSession + $OobPinAuthRequest['Body'] = @{ + SessionId = $IDSession.IdpLoginSessionId + MechanismId = 'OOBAUTHPIN' + Action = 'Answer' + Answer = Unprotect-Answer $Pin + } | ConvertTo-Json + + $IDSession = Invoke-IDRestMethod @OobPinAuthRequest + + } else { + + $OobAuthStatusRequest = @{ } + $OobAuthStatusRequest['Method'] = 'POST' + #Undocumented endpoint for checking the IdpLoginSessionId's status. Sniffed out from the ark-sdk-python project + $OobAuthStatusRequest['Uri'] = "$tenant_url/Security/OobAuthStatus" + #We need the cookies the server provides in the same response it provides the IdpAuth information + $OobAuthStatusRequest['WebSession'] = $ISPSSSession.WebSession + $OobAuthStatusRequest['Body'] = @{SessionId = $IDSession.IdpLoginSessionId} | ConvertTo-Json - $IDSession = Invoke-IDRestMethod @OobAuthStatusRequest + $IDSession = Invoke-IDRestMethod @OobAuthStatusRequest - while ($IDSession.State -ne 'Success') { - Start-Sleep 2 + while ($IDSession.State -ne 'Success') { + Start-Sleep 2 + + $IDSession = Invoke-IDRestMethod @OobAuthStatusRequest + } - $IDSession = Invoke-IDRestMethod @OobAuthStatusRequest } break From 212118bf36524c2a59abb02fb2de1984df4a5378 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 10 Apr 2026 12:38:46 +0200 Subject: [PATCH 3/8] document OOB IdP PIN fix in CHANGELOG entry under unreleased/Fixed for the new PIN-confirmation variant of OOB IdP authentication in New-IDSession. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d33cd..1aee181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project will be documented in this file. ### Changed - N/A +### Fixed +- `New-IDSession`: Adds support for OOB IdP Authentication flows that require a PIN code. + - Tenants configured to display a PIN in the browser after external IdP login are now prompted for the PIN and completed via `AdvanceAuthentication`. Previously these tenants would hang in the `OobAuthStatus` polling loop with no way to enter the PIN. + ## [0.3] - 2025-03-09 ### Added From a0595c8401d5cdb5af8005893bf7e974a54e5cb2 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 10 Apr 2026 12:46:15 +0200 Subject: [PATCH 4/8] add failing tests for PIN error handling exercises the new branch's cleanup + response validation: API error during PIN submission calls Clear-AdvanceAuthentication and re-throws; a non-LoginSuccess summary or missing token is rejected with cleanup. --- Tests/New-IDSession.Tests.ps1 | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/Tests/New-IDSession.Tests.ps1 b/Tests/New-IDSession.Tests.ps1 index 7241417..7a757bb 100644 --- a/Tests/New-IDSession.Tests.ps1 +++ b/Tests/New-IDSession.Tests.ps1 @@ -307,6 +307,60 @@ Describe $($PSCommandPath -Replace '.Tests.ps1') { } + Context 'PIN required - error handling' { + + BeforeEach { + + Mock Start-Authentication -MockWith { + [pscustomobject]@{ + TenantId = 'SomeID' + SessionId = 'SomeSession' + IdpRedirectShortUrl = 'https://short.example/x' + IdpLoginSessionId = 'IDP-123' + IdpOobAuthPinRequired = $true + } + } + + Mock Read-Host -MockWith { + ConvertTo-SecureString 'TEST-PIN' -AsPlainText -Force + } + + Mock Clear-AdvanceAuthentication -MockWith {} + + } + + It 'invokes Clear-AdvanceAuthentication and re-throws on API error' { + Mock Invoke-IDRestMethod -MockWith { throw 'Wrong PIN' } + { New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds } | + Should -Throw -ExpectedMessage 'Wrong PIN' + Assert-MockCalled -CommandName Clear-AdvanceAuthentication -Times 1 -Exactly -Scope It + } + + It 'invokes Clear-AdvanceAuthentication and throws on non-LoginSuccess response' { + Mock Invoke-IDRestMethod -MockWith { + [pscustomobject]@{ + Summary = 'SomeOtherSummary' + Token = 'SomeToken' + } + } + { New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds } | + Should -Throw + Assert-MockCalled -CommandName Clear-AdvanceAuthentication -Times 1 -Exactly -Scope It + } + + It 'invokes Clear-AdvanceAuthentication and throws when Token is missing' { + Mock Invoke-IDRestMethod -MockWith { + [pscustomobject]@{ + Summary = 'LoginSuccess' + } + } + { New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds } | + Should -Throw + Assert-MockCalled -CommandName Clear-AdvanceAuthentication -Times 1 -Exactly -Scope It + } + + } + Context 'Polling (no PIN required)' { BeforeEach { From f617b401ba61619616ef73ee45316b49a9de80f4 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 10 Apr 2026 12:46:25 +0200 Subject: [PATCH 5/8] wrap PIN submission in try/catch with cleanup and response validation mirrors the error-handling contract of Start-AdvanceAuthentication: on any failure at the PIN submission stage, Clear-AdvanceAuthentication is invoked to clean up the in-progress session and the original error is re-thrown. a successful response is additionally validated to have Summary=LoginSuccess and a Token before flowing into the downstream session output path. --- IdentityCommand/Public/New-IDSession.ps1 | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/IdentityCommand/Public/New-IDSession.ps1 b/IdentityCommand/Public/New-IDSession.ps1 index 74ceae5..e77b8f6 100644 --- a/IdentityCommand/Public/New-IDSession.ps1 +++ b/IdentityCommand/Public/New-IDSession.ps1 @@ -98,9 +98,8 @@ $($IDSession.IdpRedirectShortUrl) if ($IDSession.IdpOobAuthPinRequired) { - #PIN-confirmation variant of OOB IdP auth. - #A PIN code is displayed in the browser after IdP login and must be entered here to complete authentication. - #Reference: ark-sdk-python __perform_pin_code_idp_authentication + #A PIN code is displayed in the browser after IdP login and must be entered here + #to complete authentication via AdvanceAuthentication. $Pin = Read-Host -Prompt 'Enter the PIN code displayed after you logged in to your identity provider' -AsSecureString $OobPinAuthRequest = @{ } @@ -114,7 +113,24 @@ $($IDSession.IdpRedirectShortUrl) Answer = Unprotect-Answer $Pin } | ConvertTo-Json - $IDSession = Invoke-IDRestMethod @OobPinAuthRequest + try { + + $IDSession = Invoke-IDRestMethod @OobPinAuthRequest + + if ($IDSession.Summary -ne 'LoginSuccess' -or -not $IDSession.Token) { + + throw 'Failed to complete OOB IdP authentication with PIN code' + + } + + } catch { + + #Cleanup Authentication on any error at the PIN submission stage + Clear-AdvanceAuthentication + + throw $PSItem + + } } else { From ec3fffe08f53f31b23e063e961d3312e76d849a1 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 10 Apr 2026 12:46:36 +0200 Subject: [PATCH 6/8] document PIN code prompting in New-IDSession help adds a description paragraph to both the markdown source and the generated help file covering the new PIN-required OOB IdP variant, so Get-Help output explains the behaviour users see at runtime. --- IdentityCommand/en-US/IdentityCommand-help.xml | 1 + docs/collections/_commands/New-IDSession.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/IdentityCommand/en-US/IdentityCommand-help.xml b/IdentityCommand/en-US/IdentityCommand-help.xml index ac6db53..91febe2 100644 --- a/IdentityCommand/en-US/IdentityCommand-help.xml +++ b/IdentityCommand/en-US/IdentityCommand-help.xml @@ -1250,6 +1250,7 @@ LastCommandResults {"success":true,"Result":{"SomeResult"}}Allows a user to provide authentication details, and satisfy any required MFA challenges. Currently supports all Identity MFA authentication mechanisms except U2F & DUO. When you specify a username associated with a SAML or OIDC-based federation, then you will be redirected to the external identity provider to authenticate. Alternatively, you can provide a SamlAssertion from a configured external IDP. + If your tenant is configured to require a PIN code after external identity provider authentication, you will be prompted to enter the PIN code displayed in the browser to complete the sign-in. diff --git a/docs/collections/_commands/New-IDSession.md b/docs/collections/_commands/New-IDSession.md index 8793501..9e9c679 100644 --- a/docs/collections/_commands/New-IDSession.md +++ b/docs/collections/_commands/New-IDSession.md @@ -31,6 +31,8 @@ Currently supports all Identity MFA authentication mechanisms except U2F & DUO. Supports federated authentication when providing a SamlAssertion from a configured external IDP. +If your tenant is configured to require a PIN code after external identity provider authentication, you will be prompted to enter the PIN code displayed in the browser to complete the sign-in. + ## EXAMPLES ### Example 1 From d62ba794932bea49b87d38452ec56bb2a0031324 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 10 Apr 2026 13:04:03 +0200 Subject: [PATCH 7/8] add failing tests for PodFqdn redirect in OOB IdP flow simulates Start-Authentication mutating ISPSSSession.tenant_url via a PodFqdn redirect and asserts the PIN and polling requests target the redirected host rather than the stale local tenant_url parameter. --- Tests/New-IDSession.Tests.ps1 | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Tests/New-IDSession.Tests.ps1 b/Tests/New-IDSession.Tests.ps1 index 7a757bb..99979f6 100644 --- a/Tests/New-IDSession.Tests.ps1 +++ b/Tests/New-IDSession.Tests.ps1 @@ -305,6 +305,24 @@ Describe $($PSCommandPath -Replace '.Tests.ps1') { Assert-MockCalled -CommandName Start-AdvanceAuthentication -Times 0 -Exactly -Scope It } + It 'builds the PIN request URI from ISPSSSession.tenant_url after a PodFqdn redirect' { + Mock Start-Authentication -MockWith { + #Simulate the PodFqdn redirect performed by Start-Authentication + $ISPSSSession.tenant_url = 'https://pod.id.cyberark.cloud' + [pscustomobject]@{ + TenantId = 'SomeID' + SessionId = 'SomeSession' + IdpRedirectShortUrl = 'https://short.example/x' + IdpLoginSessionId = 'IDP-123' + IdpOobAuthPinRequired = $true + } + } + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Invoke-IDRestMethod -Times 1 -Exactly -Scope It -ParameterFilter { + $Uri -eq 'https://pod.id.cyberark.cloud/Security/AdvanceAuthentication' + } + } + } Context 'PIN required - error handling' { @@ -398,6 +416,23 @@ Describe $($PSCommandPath -Replace '.Tests.ps1') { Should -Not -Throw } + It 'builds the polling request URI from ISPSSSession.tenant_url after a PodFqdn redirect' { + Mock Start-Authentication -MockWith { + #Simulate the PodFqdn redirect performed by Start-Authentication + $ISPSSSession.tenant_url = 'https://pod.id.cyberark.cloud' + [pscustomobject]@{ + TenantId = 'SomeID' + SessionId = 'SomeSession' + IdpRedirectShortUrl = 'https://short.example/x' + IdpLoginSessionId = 'IDP-123' + } + } + New-IDSession -tenant_url https://somedomain.id.cyberark.cloud -Credential $Creds + Assert-MockCalled -CommandName Invoke-IDRestMethod -Times 1 -Exactly -Scope It -ParameterFilter { + $Uri -eq 'https://pod.id.cyberark.cloud/Security/OobAuthStatus' + } + } + } } From 28a0e9168428a6559e193bb2cca9d4fb37f7f490 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 10 Apr 2026 13:04:14 +0200 Subject: [PATCH 8/8] build OOB IdP request URIs from ISPSSSession.tenant_url Start-Authentication rewrites the session tenant_url when the server returns a PodFqdn redirect, so the OOB IdP PIN submission and the polling fallback must build their request URIs from the session variable rather than the original function parameter. previously both branches used the stale local tenant_url and would hit the wrong host for tenants that redirect to a pod. flagged by Copilot on PR #23. --- IdentityCommand/Public/New-IDSession.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/IdentityCommand/Public/New-IDSession.ps1 b/IdentityCommand/Public/New-IDSession.ps1 index e77b8f6..083f055 100644 --- a/IdentityCommand/Public/New-IDSession.ps1 +++ b/IdentityCommand/Public/New-IDSession.ps1 @@ -104,7 +104,8 @@ $($IDSession.IdpRedirectShortUrl) $OobPinAuthRequest = @{ } $OobPinAuthRequest['Method'] = 'POST' - $OobPinAuthRequest['Uri'] = "$tenant_url/Security/AdvanceAuthentication" + #Use the session tenant_url so that any PodFqdn redirect from Start-Authentication is honoured + $OobPinAuthRequest['Uri'] = "$($ISPSSSession.tenant_url)/Security/AdvanceAuthentication" $OobPinAuthRequest['WebSession'] = $ISPSSSession.WebSession $OobPinAuthRequest['Body'] = @{ SessionId = $IDSession.IdpLoginSessionId @@ -137,7 +138,8 @@ $($IDSession.IdpRedirectShortUrl) $OobAuthStatusRequest = @{ } $OobAuthStatusRequest['Method'] = 'POST' #Undocumented endpoint for checking the IdpLoginSessionId's status. Sniffed out from the ark-sdk-python project - $OobAuthStatusRequest['Uri'] = "$tenant_url/Security/OobAuthStatus" + #Use the session tenant_url so that any PodFqdn redirect from Start-Authentication is honoured + $OobAuthStatusRequest['Uri'] = "$($ISPSSSession.tenant_url)/Security/OobAuthStatus" #We need the cookies the server provides in the same response it provides the IdpAuth information $OobAuthStatusRequest['WebSession'] = $ISPSSSession.WebSession $OobAuthStatusRequest['Body'] = @{SessionId = $IDSession.IdpLoginSessionId} | ConvertTo-Json