diff --git a/ServiceNow/Private/Get-ServiceNowAuth.ps1 b/ServiceNow/Private/Get-ServiceNowAuth.ps1 index b0afa3b..4f652fc 100644 --- a/ServiceNow/Private/Get-ServiceNowAuth.ps1 +++ b/ServiceNow/Private/Get-ServiceNowAuth.ps1 @@ -38,31 +38,52 @@ function Get-ServiceNowAuth { if ($ServiceNowSession.Version) { $hashOut.uri = $hashOut.uri + $ServiceNowSession.Version } # check if we need a new access token - if ( $ServiceNowSession.ExpiresOn -lt (Get-Date) -and $ServiceNowSession.RefreshToken -and $ServiceNowSession.ClientCredential ) { - # we've expired and have a refresh token - $refreshParams = @{ - Uri = 'https://{0}/oauth_token.do' -f $ServiceNowSession.Domain - Method = 'POST' - ContentType = 'application/x-www-form-urlencoded' - Body = @{ - grant_type = 'refresh_token' - client_id = $ServiceNowSession.ClientCredential.UserName - client_secret = $ServiceNowSession.ClientCredential.GetNetworkCredential().password - refresh_token = $ServiceNowSession.RefreshToken.GetNetworkCredential().password - } + if ( $ServiceNowSession.ExpiresOn -lt (Get-Date) -and $ServiceNowSession.ClientCredential ) { + + # Build refresh/re-auth body based on grant type + $refreshBody = @{ + client_id = $ServiceNowSession.ClientCredential.UserName + client_secret = $ServiceNowSession.ClientCredential.GetNetworkCredential().password } - $response = Invoke-RestMethod @refreshParams - - $ServiceNowSession.AccessToken = New-Object System.Management.Automation.PSCredential('AccessToken', ($response.access_token | ConvertTo-SecureString -AsPlainText -Force)) - $ServiceNowSession.RefreshToken = New-Object System.Management.Automation.PSCredential('RefreshToken', ($response.refresh_token | ConvertTo-SecureString -AsPlainText -Force)) - if ($response.expires_in) { - $ServiceNowSession.ExpiresOn = (Get-Date).AddSeconds($response.expires_in) - Write-Verbose ('Access token has been refreshed and will expire at {0}' -f $ServiceNowSession.ExpiresOn) + if ($ServiceNowSession.GrantType -eq 'client_credentials') { + # Client credentials: re-authenticate (no refresh token available) + $refreshBody['grant_type'] = 'client_credentials' + } + elseif ($ServiceNowSession.RefreshToken) { + # Password grant: use refresh token + $refreshBody['grant_type'] = 'refresh_token' + $refreshBody['refresh_token'] = $ServiceNowSession.RefreshToken.GetNetworkCredential().password } + else { + Write-Warning 'Access token expired but no refresh method available' + } + + if ($refreshBody.ContainsKey('grant_type')) { + $refreshParams = @{ + Uri = 'https://{0}/oauth_token.do' -f $ServiceNowSession.Domain + Method = 'POST' + ContentType = 'application/x-www-form-urlencoded' + Body = $refreshBody + } - # ensure script/module scoped variable is updated - $script:ServiceNowSession = $ServiceNowSession + $response = Invoke-RestMethod @refreshParams + + $ServiceNowSession.AccessToken = New-Object System.Management.Automation.PSCredential('AccessToken', ($response.access_token | ConvertTo-SecureString -AsPlainText -Force)) + + # Update refresh token if provided (password grant only) + if ($response.refresh_token) { + $ServiceNowSession.RefreshToken = New-Object System.Management.Automation.PSCredential('RefreshToken', ($response.refresh_token | ConvertTo-SecureString -AsPlainText -Force)) + } + + if ($response.expires_in) { + $ServiceNowSession.ExpiresOn = (Get-Date).AddSeconds($response.expires_in) + Write-Verbose ('Access token has been refreshed and will expire at {0}' -f $ServiceNowSession.ExpiresOn) + } + + # ensure script/module scoped variable is updated + $script:ServiceNowSession = $ServiceNowSession + } } if ( $ServiceNowSession.AccessToken ) { diff --git a/ServiceNow/Public/New-ServiceNowSession.ps1 b/ServiceNow/Public/New-ServiceNowSession.ps1 index dca4e3b..6724ebe 100644 --- a/ServiceNow/Public/New-ServiceNowSession.ps1 +++ b/ServiceNow/Public/New-ServiceNowSession.ps1 @@ -15,10 +15,12 @@ If using OAuth, the client credential will be stored in the script scoped variab Base domain for your ServiceNow instance, eg. tenant.domain.com .PARAMETER Credential -Username and password to connect. This can be used standalone to use basic authentication or in conjunction with ClientCredential for OAuth. +Username and password to connect. This can be used standalone to use basic authentication or in conjunction with ClientCredential for OAuth password grant. +Not required when using ClientCredential alone for the OAuth client_credentials grant (machine-to-machine). .PARAMETER ClientCredential Required for OAuth. Credential where the username is the Client ID and the password is the Secret. +If provided along with Credential, the OAuth password grant is used. If provided alone, the OAuth client_credentials grant is used, which does not require user credentials and is useful for machine-to-machine authentication or when MFA is enforced for interactive users. .PARAMETER AccessToken Provide the access token directly if obtained outside of this module. @@ -58,7 +60,11 @@ Use GraphQL instead of REST. .EXAMPLE New-ServiceNowSession -Url tenant.domain.com -Credential $mycred -ClientCredential $myClientCred -Create a session using OAuth and save it as the default +Create a session using OAuth password grant and save it as the default + +.EXAMPLE +New-ServiceNowSession -Url tenant.domain.com -ClientCredential $myClientCred +Create a session using the OAuth client_credentials grant (machine-to-machine, no user context) and save it as the default .EXAMPLE New-ServiceNowSession -Url tenant.domain.com -AccessToken 'asdfasd9f87adsfkksk3nsnd87g6s' @@ -101,6 +107,8 @@ function New-ServiceNowSession { [Parameter(Mandatory, ParameterSetName = 'OAuth')] [Parameter(Mandatory, ParameterSetName = 'OAuthProxy')] + [Parameter(Mandatory, ParameterSetName = 'OAuthClientCredential')] + [Parameter(Mandatory, ParameterSetName = 'OAuthClientCredentialProxy')] [System.Management.Automation.PSCredential] $ClientCredential, [Parameter(Mandatory, ParameterSetName = 'AccessToken')] @@ -109,11 +117,13 @@ function New-ServiceNowSession { [Parameter(Mandatory, ParameterSetName = 'BasicAuthProxy')] [Parameter(Mandatory, ParameterSetName = 'OAuthProxy')] + [Parameter(Mandatory, ParameterSetName = 'OAuthClientCredentialProxy')] [Parameter(Mandatory, ParameterSetName = 'AccessTokenProxy')] [string] $Proxy, [Parameter(ParameterSetName = 'BasicAuthProxy')] [Parameter(ParameterSetName = 'OAuthProxy')] + [Parameter(ParameterSetName = 'OAuthClientCredentialProxy')] [Parameter(ParameterSetName = 'AccessTokenProxy')] [System.Management.Automation.PSCredential] $ProxyCredential, @@ -160,19 +170,32 @@ function New-ServiceNowSession { } $script:PSDefaultParameterValues['Invoke-WebRequest:TimeoutSec'] = $TimeoutSec - $script:PSDefaultParameterValues['Invoke-RestMethodt:TimeoutSec'] = $TimeoutSec + $script:PSDefaultParameterValues['Invoke-RestMethod:TimeoutSec'] = $TimeoutSec switch -Wildcard ($PSCmdLet.ParameterSetName) { 'OAuth*' { + # Determine OAuth grant type and build request body + $oauthBody = @{ + 'client_id' = $ClientCredential.UserName + 'client_secret' = $ClientCredential.GetNetworkCredential().Password + } + + if ($PSCmdLet.ParameterSetName -like 'OAuthClientCredential*') { + # Client Credentials Grant (machine-to-machine) + $oauthBody['grant_type'] = 'client_credentials' + $grantType = 'client_credentials' + } + else { + # Password Grant (user credentials) + $oauthBody['grant_type'] = 'password' + $oauthBody['username'] = $Credential.UserName + $oauthBody['password'] = $Credential.GetNetworkCredential().Password + $grantType = 'password' + } + $params = @{ Uri = 'https://{0}/oauth_token.do' -f $Url - Body = @{ - 'grant_type' = 'password' - 'client_id' = $ClientCredential.UserName - 'client_secret' = $ClientCredential.GetNetworkCredential().Password - 'username' = $Credential.UserName - 'password' = $Credential.GetNetworkCredential().Password - } + Body = $oauthBody Method = 'Post' UseBasicParsing = $true } @@ -198,18 +221,24 @@ function New-ServiceNowSession { if ( $response.Content ) { $token = $response.Content | ConvertFrom-Json $newSession.Add('AccessToken', (New-Object System.Management.Automation.PSCredential('AccessToken', ($token.access_token | ConvertTo-SecureString -AsPlainText -Force)))) - $newSession.Add('RefreshToken', (New-Object System.Management.Automation.PSCredential('RefreshToken', ($token.refresh_token | ConvertTo-SecureString -AsPlainText -Force)))) + + # Password grant returns refresh_token, client credentials does not + if ($token.refresh_token) { + $newSession.Add('RefreshToken', (New-Object System.Management.Automation.PSCredential('RefreshToken', ($token.refresh_token | ConvertTo-SecureString -AsPlainText -Force)))) + } + if ($token.expires_in) { $expiryTime = (Get-Date).AddSeconds($token.expires_in) $newSession.Add('ExpiresOn', $expiryTime) Write-Verbose "Access token will expire at $expiryTime" } - # store client credential as it will be needed to refresh the access token - $newSession.Add('ClientCredential', $ClientCredential) + # Store credentials and grant type for token refresh + $newSession.Add('ClientCredential', $ClientCredential) + $newSession.Add('GrantType', $grantType) } else { # invoke-webrequest didn't throw an error, but we didn't get a token back either - throw ('"{0} : {1}' -f $response.StatusCode, $response | Out-String ) + throw ('{0} : {1}' -f $response.StatusCode, $response | Out-String ) } } diff --git a/Tests/OAuthClientCredential.Tests.ps1 b/Tests/OAuthClientCredential.Tests.ps1 new file mode 100644 index 0000000..107435e --- /dev/null +++ b/Tests/OAuthClientCredential.Tests.ps1 @@ -0,0 +1,186 @@ +$ProjectRoot = Resolve-Path "$PSScriptRoot/.." +$ModulePsd = (Resolve-Path "$ProjectRoot/ServiceNow/ServiceNow.psd1").Path + +Get-Module 'ServiceNow' | Remove-Module -Force -ErrorAction SilentlyContinue +Import-Module $ModulePsd -Force + +Describe 'New-ServiceNowSession OAuth Client Credentials Grant' { + + BeforeAll { + $script:testClientCredential = [PSCredential]::new('myClientId', ([System.Net.NetworkCredential]::new('', 'myClientSecret').SecurePassword)) + } + + Context 'Client credentials grant without proxy' { + + It 'Authenticates using client_credentials grant type' { + Mock Invoke-WebRequest -ModuleName 'ServiceNow' { + [PSCustomObject]@{ + StatusCode = 200 + Content = '{"access_token": "abc123", "expires_in": 1800}' + } + } + + $session = New-ServiceNowSession -Url 'test.service-now.com' -ClientCredential $script:testClientCredential -PassThru + + $session.GrantType | Should -Be 'client_credentials' + $session.AccessToken | Should -Not -BeNullOrEmpty + $session.RefreshToken | Should -BeNullOrEmpty + $session.ClientCredential | Should -Be $script:testClientCredential + + Should -Invoke Invoke-WebRequest -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter { + $Body.grant_type -eq 'client_credentials' + } + } + } + + Context 'Client credentials grant with proxy' { + + It 'Accepts ClientCredential and Proxy without requiring Credential' { + Mock Invoke-WebRequest -ModuleName 'ServiceNow' { + [PSCustomObject]@{ + StatusCode = 200 + Content = '{"access_token": "abc123", "expires_in": 1800}' + } + } + + $session = New-ServiceNowSession -Url 'test.service-now.com' -ClientCredential $script:testClientCredential -Proxy 'http://proxy.example.com:8080' -PassThru + + $session.GrantType | Should -Be 'client_credentials' + $session.Proxy | Should -Be 'http://proxy.example.com:8080' + + Should -Invoke Invoke-WebRequest -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter { + $Proxy -eq 'http://proxy.example.com:8080' + } + } + + It 'Accepts ClientCredential, Proxy, and ProxyCredential together' { + Mock Invoke-WebRequest -ModuleName 'ServiceNow' { + [PSCustomObject]@{ + StatusCode = 200 + Content = '{"access_token": "abc123", "expires_in": 1800}' + } + } + + $proxyCred = [PSCredential]::new('proxyuser', ([System.Net.NetworkCredential]::new('', 'proxypass').SecurePassword)) + + { New-ServiceNowSession -Url 'test.service-now.com' -ClientCredential $script:testClientCredential -Proxy 'http://proxy.example.com:8080' -ProxyCredential $proxyCred -PassThru } | Should -Not -Throw + } + } + + Context 'Password grant still works alongside client credentials support' { + + It 'Authenticates using password grant type when Credential and ClientCredential are both provided' { + Mock Invoke-WebRequest -ModuleName 'ServiceNow' { + [PSCustomObject]@{ + StatusCode = 200 + Content = '{"access_token": "abc123", "refresh_token": "refresh456", "expires_in": 1800}' + } + } + + $userCred = [PSCredential]::new('user', ([System.Net.NetworkCredential]::new('', 'pass').SecurePassword)) + + $session = New-ServiceNowSession -Url 'test.service-now.com' -Credential $userCred -ClientCredential $script:testClientCredential -PassThru + + $session.GrantType | Should -Be 'password' + $session.RefreshToken | Should -Not -BeNullOrEmpty + + Should -Invoke Invoke-WebRequest -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter { + $Body.grant_type -eq 'password' + } + } + } +} + +Describe 'Get-ServiceNowAuth Token Refresh Logic' { + + Context 'Client credentials grant refresh' { + + It 'Re-authenticates using client_credentials when token expired and no refresh token exists' { + Mock Invoke-RestMethod -ModuleName 'ServiceNow' { + [PSCustomObject]@{ + access_token = 'newtoken123' + expires_in = 1800 + } + } + + $session = @{ + Domain = 'test.service-now.com' + BaseUri = 'https://test.service-now.com/api/' + Version = '' + ClientCredential = [PSCredential]::new('clientid', ([System.Net.NetworkCredential]::new('', 'secret').SecurePassword)) + GrantType = 'client_credentials' + ExpiresOn = (Get-Date).AddMinutes(-5) + } + + InModuleScope 'ServiceNow' -Parameters @{ session = $session } { + param($session) + $result = Get-ServiceNowAuth -ServiceNowSession $session + $result | Should -Not -BeNullOrEmpty + $session.AccessToken | Should -Not -BeNullOrEmpty + } + + Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter { + $Body.grant_type -eq 'client_credentials' + } + } + } + + Context 'Password grant refresh' { + + It 'Uses refresh_token grant when a refresh token is available' { + Mock Invoke-RestMethod -ModuleName 'ServiceNow' { + [PSCustomObject]@{ + access_token = 'newtoken123' + refresh_token = 'newrefresh456' + expires_in = 1800 + } + } + + $session = @{ + Domain = 'test.service-now.com' + BaseUri = 'https://test.service-now.com/api/' + Version = '' + ClientCredential = [PSCredential]::new('clientid', ([System.Net.NetworkCredential]::new('', 'secret').SecurePassword)) + RefreshToken = [PSCredential]::new('RefreshToken', ([System.Net.NetworkCredential]::new('', 'oldrefresh').SecurePassword)) + GrantType = 'password' + ExpiresOn = (Get-Date).AddMinutes(-5) + } + + InModuleScope 'ServiceNow' -Parameters @{ session = $session } { + param($session) + $null = Get-ServiceNowAuth -ServiceNowSession $session + $session.RefreshToken | Should -Not -BeNullOrEmpty + } + + Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 1 -Exactly -ParameterFilter { + $Body.grant_type -eq 'refresh_token' + } + } + } + + Context 'No refresh method available' { + + It 'Warns and does not call Invoke-RestMethod when no refresh token or client credentials grant type exists' { + Mock Invoke-RestMethod -ModuleName 'ServiceNow' {} + Mock Write-Warning -ModuleName 'ServiceNow' {} + + $session = @{ + Domain = 'test.service-now.com' + BaseUri = 'https://test.service-now.com/api/' + Version = '' + ClientCredential = [PSCredential]::new('clientid', ([System.Net.NetworkCredential]::new('', 'secret').SecurePassword)) + AccessToken = [PSCredential]::new('AccessToken', ([System.Net.NetworkCredential]::new('', 'staletoken').SecurePassword)) + GrantType = 'password' + ExpiresOn = (Get-Date).AddMinutes(-5) + } + + InModuleScope 'ServiceNow' -Parameters @{ session = $session } { + param($session) + $null = Get-ServiceNowAuth -ServiceNowSession $session + } + + Should -Invoke Invoke-RestMethod -ModuleName 'ServiceNow' -Times 0 + Should -Invoke Write-Warning -ModuleName 'ServiceNow' -Times 1 + } + } +}