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 diff --git a/IdentityCommand/Public/New-IDSession.ps1 b/IdentityCommand/Public/New-IDSession.ps1 index d02ae85..083f055 100644 --- a/IdentityCommand/Public/New-IDSession.ps1 +++ b/IdentityCommand/Public/New-IDSession.ps1 @@ -96,20 +96,62 @@ $($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) { - $IDSession = Invoke-IDRestMethod @OobAuthStatusRequest + #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 - while ($IDSession.State -ne 'Success') { - Start-Sleep 2 + $OobPinAuthRequest = @{ } + $OobPinAuthRequest['Method'] = 'POST' + #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 + MechanismId = 'OOBAUTHPIN' + Action = 'Answer' + Answer = Unprotect-Answer $Pin + } | ConvertTo-Json + + 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 { + + $OobAuthStatusRequest = @{ } + $OobAuthStatusRequest['Method'] = 'POST' + #Undocumented endpoint for checking the IdpLoginSessionId's status. Sniffed out from the ark-sdk-python project + #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 $IDSession = Invoke-IDRestMethod @OobAuthStatusRequest + + while ($IDSession.State -ne 'Success') { + Start-Sleep 2 + + $IDSession = Invoke-IDRestMethod @OobAuthStatusRequest + } + } break 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/Tests/New-IDSession.Tests.ps1 b/Tests/New-IDSession.Tests.ps1 index de6d68b..99979f6 100644 --- a/Tests/New-IDSession.Tests.ps1 +++ b/Tests/New-IDSession.Tests.ps1 @@ -222,6 +222,221 @@ 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 + } + + 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' { + + 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 { + + 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 + } + + 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' + } + } + + } + + } + Context 'Output' { BeforeEach { 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