Private/NCRestAPI.ps1
|
<# .SYNOPSIS NCRestAPI class: handles authentication, token lifecycle, and HTTP requests against the N-central REST API. .DESCRIPTION Tokens are held in memory as [SecureString] and never written to environment variables or disk. Plaintext is only materialized inside method calls via ConvertFromSecureString helpers that zero unmanaged BSTR buffers. HTTP calls are funneled through a single Invoke() that retries on 429/5xx with exponential backoff, propagates rich error information, and scrubs tokens from verbose output. .NOTES Author: Zach Frazier Website: https://github.com/soybigmac/NCRestAPI #> class NCRestAPI { [string]$BaseUrl hidden [securestring]$ApiToken hidden [securestring]$AccessToken hidden [securestring]$RefreshToken [string]$AccessTokenExpiration [string]$RefreshTokenExpiration [int]$TimeoutSec = 60 [int]$MaxRetries = 3 # Client-side pacing between requests in ms. 0 = no throttle. Useful during # big -All pulls or pipeline fan-out against rate-limited tenants. [int]$ThrottleMs = 0 [bool]$Verbose hidden [bool]$InPager = $false hidden [datetime]$LastRequestAt = [datetime]::MinValue NCRestAPI([string]$baseUrl, [securestring]$apiToken, [string]$accessTokenExpiration, [string]$refreshTokenExpiration, [bool]$verbose = $false) { $this.BaseUrl = $baseUrl $this.ApiToken = $apiToken $this.AccessTokenExpiration = $accessTokenExpiration $this.RefreshTokenExpiration = $refreshTokenExpiration $this.Verbose = $verbose $this.Authenticate() } static [securestring] ToSecureString([string]$plain) { $s = New-Object System.Security.SecureString foreach ($c in $plain.ToCharArray()) { $s.AppendChar($c) } $s.MakeReadOnly() return $s } hidden [string] Reveal([securestring]$s) { if (-not $s) { return $null } $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } hidden [void] Log([string]$message) { if (-not $this.Verbose) { return } $scrubbed = $message ` -replace '(?i)(Bearer\s+)[A-Za-z0-9\-\._~\+\/=]+', '$1[MASKED]' ` -replace '(?i)("(?:access|refresh|api)?token"\s*:\s*")[^"]+', '$1[MASKED]' ` -replace '(?i)("token"\s*:\s*")[^"]+', '$1[MASKED]' Write-Verbose $scrubbed } hidden [hashtable] AuthHeaders([securestring]$token) { return @{ 'Accept' = '*/*' 'Authorization' = "Bearer $($this.Reveal($token))" 'Content-Type' = 'application/json' } } [void] Authenticate() { $this.Log("[NCRESTAPI] Authenticate: starting.") $url = "$($this.BaseUrl)/api/auth/authenticate" $headers = @{ 'Accept' = '*/*' 'Authorization' = "Bearer $($this.Reveal($this.ApiToken))" } if ($this.RefreshTokenExpiration) { $headers['X-REFRESH-EXPIRY-OVERRIDE'] = $this.RefreshTokenExpiration } if ($this.AccessTokenExpiration) { $headers['X-ACCESS-EXPIRY-OVERRIDE'] = $this.AccessTokenExpiration } try { $response = Invoke-RestMethod -Uri $url -Headers $headers -Method Post -Body '' -TimeoutSec $this.TimeoutSec } catch { $this.Log("[NCRESTAPI] Authenticate: failed: $($_.Exception.Message)") throw "[NCRESTAPI] Authentication failed: $($_.Exception.Message)" } if (-not $response.tokens.access.token -or -not $response.tokens.refresh.token) { throw "[NCRESTAPI] Authenticate: response missing tokens." } $this.AccessToken = [NCRestAPI]::ToSecureString($response.tokens.access.token) $this.RefreshToken = [NCRestAPI]::ToSecureString($response.tokens.refresh.token) $this.Log("[NCRESTAPI] Authenticate: succeeded.") } [bool] ValidateToken() { if (-not $this.AccessToken) { return $false } $url = "$($this.BaseUrl)/api/auth/validate" try { $response = Invoke-RestMethod -Uri $url -Headers $this.AuthHeaders($this.AccessToken) -Method Get -TimeoutSec $this.TimeoutSec return $response.message -eq 'The token is valid.' } catch { $this.Log("[NCRESTAPI] ValidateToken: $($_.Exception.Message)") return $false } } [void] RefreshAccessToken() { $this.Log("[NCRESTAPI] RefreshAccessToken: starting.") if (-not $this.RefreshToken) { throw "[NCRESTAPI] No refresh token available." } $url = "$($this.BaseUrl)/api/auth/refresh" $refreshPlain = $this.Reveal($this.RefreshToken) $headers = @{ 'Accept' = '*/*' 'Authorization' = "Bearer $refreshPlain" 'Content-Type' = 'text/plain' } try { $response = Invoke-RestMethod -Uri $url -Headers $headers -Method Post -Body $refreshPlain -TimeoutSec $this.TimeoutSec } catch { $this.Log("[NCRESTAPI] RefreshAccessToken: $($_.Exception.Message). Re-authenticating.") $this.Authenticate() return } if (-not $response.tokens.access.token) { throw "[NCRESTAPI] RefreshAccessToken: response missing tokens." } $this.AccessToken = [NCRestAPI]::ToSecureString($response.tokens.access.token) if ($response.tokens.refresh.token) { $this.RefreshToken = [NCRestAPI]::ToSecureString($response.tokens.refresh.token) } } [void] EnsureValidToken() { if (-not $this.AccessToken) { $this.Authenticate(); return } if (-not $this.ValidateToken()) { $this.RefreshAccessToken() } } hidden [object] Invoke([string]$method, [string]$endpoint, [object]$body) { $this.EnsureValidToken() $url = "$($this.BaseUrl)/$($endpoint.TrimStart('/'))" $headers = $this.AuthHeaders($this.AccessToken) $payload = $null if ($null -ne $body) { if ($body -is [string]) { $payload = $body } else { $payload = $body | ConvertTo-Json -Depth 10 } } $this.Log("[NCRESTAPI] $method $url") $attempt = 0 while ($true) { $attempt++ if ($this.ThrottleMs -gt 0 -and $this.LastRequestAt -ne [datetime]::MinValue) { $elapsed = ([datetime]::UtcNow - $this.LastRequestAt).TotalMilliseconds $wait = $this.ThrottleMs - $elapsed if ($wait -gt 0) { Start-Sleep -Milliseconds ([int]$wait) } } $this.LastRequestAt = [datetime]::UtcNow try { $params = @{ Uri = $url Headers = $headers Method = $method TimeoutSec = $this.TimeoutSec ErrorAction = 'Stop' } if ($null -ne $payload) { $params.Body = $payload } $response = Invoke-RestMethod @params if ($response -and $response.PSObject.Properties['data']) { $totalItems = $null; $itemCount = $null; $pageNumber = $null; $totalPages = $null if ($response.PSObject.Properties['totalItems']) { $totalItems = $response.totalItems } if ($response.PSObject.Properties['itemCount']) { $itemCount = $response.itemCount } if ($response.PSObject.Properties['pageNumber']) { $pageNumber = $response.pageNumber } if ($response.PSObject.Properties['totalPages']) { $totalPages = $response.totalPages } if ($null -ne $totalItems) { $this.Log("[NCRESTAPI] page=$pageNumber/$totalPages itemCount=$itemCount totalItems=$totalItems") } if (-not $this.InPager -and $null -ne $totalItems -and $null -ne $itemCount -and $totalItems -gt $itemCount) { Write-Warning "[NCRestAPI] $method $endpoint returned $itemCount of $totalItems items. Pass -All to fetch the rest, or increase -PageSize." } return $response.data } return $response } catch { $status = $null $response = $_.Exception.Response if ($response) { $status = [int]$response.StatusCode } $ex = $_.Exception # Transport-level failures (connection aborted, socket reset, DNS hiccup, # read timeout on an idle connection) don't produce a response object, so we # also retry by exception type. Caps at $this.MaxRetries either way. $transportRetry = $false if (-not $response) { $t = $ex.GetType().FullName if ($t -match 'HttpRequestException|IOException|WebException|SocketException|TaskCanceledException') { $transportRetry = $true } } $retriable = ($status -eq 429) -or ($status -ge 500 -and $status -lt 600) -or $transportRetry if ($retriable -and $attempt -le $this.MaxRetries) { # Honor Retry-After if the server sent one (seconds, or HTTP-date). $delay = [math]::Pow(2, $attempt - 1) if ($response -and $response.Headers) { $ra = $null try { $ra = $response.Headers['Retry-After'] } catch { $ra = $null } if (-not $ra -and $response.Headers.GetEnumerator) { foreach ($h in $response.Headers) { if ($h.Key -eq 'Retry-After') { $ra = $h.Value; break } } } if ($ra) { $raSeconds = 0 if ([int]::TryParse($ra, [ref]$raSeconds) -and $raSeconds -gt 0) { $delay = [math]::Min($raSeconds, 60) } else { # HTTP-date is always GMT per RFC 7231; parse as UTC to avoid # local-timezone skew when compared to UtcNow. $raDate = [datetime]::MinValue $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal if ([datetime]::TryParse($ra, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$raDate)) { $diff = ($raDate - [datetime]::UtcNow).TotalSeconds if ($diff -gt 0) { $delay = [math]::Min($diff, 60) } } } } } $why = if ($transportRetry) { "transport $($ex.GetType().Name)" } else { "HTTP $status" } $this.Log("[NCRESTAPI] $method $($endpoint): $why, retry $attempt/$($this.MaxRetries) after $($delay)s.") Start-Sleep -Seconds $delay continue } $detail = $_.ErrorDetails.Message if (-not $detail) { $detail = $_.Exception.Message } $this.Log("[NCRESTAPI] $method $endpoint failed (HTTP $status): $detail") throw "[NCRESTAPI] $method $endpoint failed (HTTP $status): $detail" } } return $null } [object] Get([string]$endpoint) { return $this.Invoke('Get', $endpoint, $null) } [object] Post([string]$endpoint, [object]$body) { return $this.Invoke('Post', $endpoint, $body) } [object] Put([string]$endpoint, [object]$body) { return $this.Invoke('Put', $endpoint, $body) } [object] Delete([string]$endpoint) { return $this.Invoke('Delete', $endpoint, $null) } [object] Delete([string]$endpoint, [object]$body) { return $this.Invoke('Delete', $endpoint, $body) } [object] Patch([string]$endpoint, [object]$body) { return $this.Invoke('Patch', $endpoint, $body) } [void] Dispose() { $this.ApiToken = $null $this.AccessToken = $null $this.RefreshToken = $null $this.BaseUrl = $null } } function ConvertTo-NCQueryString { [CmdletBinding()] param([hashtable]$Parameters) if (-not $Parameters -or $Parameters.Count -eq 0) { return '' } $pairs = foreach ($kv in $Parameters.GetEnumerator()) { if ($null -eq $kv.Value -or $kv.Value -eq '') { continue } '{0}={1}' -f [uri]::EscapeDataString([string]$kv.Key), [uri]::EscapeDataString([string]$kv.Value) } if (-not $pairs) { return '' } return '?' + ($pairs -join '&') } function Invoke-NCPagedRequest { <# .SYNOPSIS Iterates an N-central paged GET endpoint until fewer than -PageSize items are returned. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Endpoint, [hashtable]$QueryParameters = @{}, [int]$PageSize = 500 ) $api = Get-NCRestApiInstance $api.InPager = $true try { $page = 1 while ($true) { $q = @{} foreach ($kv in $QueryParameters.GetEnumerator()) { $q[$kv.Key] = $kv.Value } $q['pageNumber'] = $page $q['pageSize'] = $PageSize $ep = "$Endpoint$(ConvertTo-NCQueryString -Parameters $q)" $batch = @($api.Get($ep)) if ($batch.Count -eq 0) { break } $batch if ($batch.Count -lt $PageSize) { break } $page++ } } finally { $api.InPager = $false } } function Get-NCRestApiInstance { [CmdletBinding()] param() if ($script:NCRestApiInstance) { return $script:NCRestApiInstance } if ($global:NCRestApiInstance) { return $global:NCRestApiInstance } throw "NCRestAPI instance is not initialized. Run Set-NCRestConfig first." } |