Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 42 additions & 21 deletions ServiceNow/Private/Get-ServiceNowAuth.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
57 changes: 43 additions & 14 deletions ServiceNow/Public/New-ServiceNowSession.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -101,6 +107,8 @@ function New-ServiceNowSession {

[Parameter(Mandatory, ParameterSetName = 'OAuth')]
[Parameter(Mandatory, ParameterSetName = 'OAuthProxy')]
[Parameter(Mandatory, ParameterSetName = 'OAuthClientCredential')]

@gdbarron gdbarron Jan 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll need to add a OAuthClientCredentialProxy paramset for ClientCredential, Proxy, and ProxyCredential params.

[Parameter(Mandatory, ParameterSetName = 'OAuthClientCredentialProxy')]
[System.Management.Automation.PSCredential] $ClientCredential,

[Parameter(Mandatory, ParameterSetName = 'AccessToken')]
Expand All @@ -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,

Expand Down Expand Up @@ -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
}
Expand All @@ -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 )
}
}

Expand Down
186 changes: 186 additions & 0 deletions Tests/OAuthClientCredential.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading