Private/Invoke-JIMApi.ps1
|
# Copyright (c) Tetron Limited. All rights reserved. # Licensed under the Tetron Commercial License. See LICENSE file in the project root. function Invoke-JIMApi { <# .SYNOPSIS Internal function to invoke JIM REST API endpoints. .DESCRIPTION This is a private helper function that handles all REST API calls to JIM. It manages authentication headers (API key or Bearer token), error handling, and response processing. Supports automatic token refresh for OAuth connections - both proactively (before the request, when the token is near expiry) and reactively (on 401 response, in case of clock skew or server-side revocation). .PARAMETER Endpoint The API endpoint path (without base URL), e.g., '/api/v1/synchronisation/connected-systems' .PARAMETER Method The HTTP method to use. Defaults to 'GET'. .PARAMETER Body Optional body for POST/PUT/PATCH requests. Will be converted to JSON. .PARAMETER ContentType Content type for the request. Defaults to 'application/json'. .OUTPUTS The API response object, or throws an error if the request fails. .EXAMPLE Invoke-JIMApi -Endpoint '/api/v1/synchronisation/connected-systems' .EXAMPLE Invoke-JIMApi -Endpoint '/api/v1/synchronisation/connected-systems' -Method 'POST' -Body @{ Name = 'Test' } #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Endpoint, [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string]$Method = 'GET', [object]$Body, [string]$ContentType = 'application/json' ) # Not connected is an expected precondition, not a failure. Report it as a # non-terminating error (matching every other cmdlet's guard) and return # nothing, rather than throwing: a raw throw makes ConciseView render this # helper's internal file and line, which reads like a crash. Callers can opt # into terminating behaviour with -ErrorAction Stop. if (-not $script:JIMConnection) { Write-Error "You are not connected to JIM. Run Connect-JIM -Url <your JIM URL> to authenticate, then try again." return } # Proactive token refresh: check before the request if token is near expiry if ($script:JIMConnection.AuthMethod -eq 'OAuth') { if ($script:JIMConnection.TokenExpiresAt -and $script:JIMConnection.TokenExpiresAt -lt (Get-Date).AddMinutes(2)) { Invoke-TokenRefresh -Reason "Access token expired or expiring soon" } } # Build and execute the request, with reactive 401 retry for OAuth $response = Invoke-JIMApiRequest -Endpoint $Endpoint -Method $Method -Body $Body -ContentType $ContentType return $response } function Invoke-TokenRefresh { <# .SYNOPSIS Refreshes the OAuth access token using the stored refresh token. #> [CmdletBinding()] param( [string]$Reason = "Token refresh required" ) if ($script:JIMConnection.RefreshToken -and $script:JIMConnection.OAuthConfig) { try { Write-Verbose "$Reason, refreshing..." $tokens = Invoke-OAuthTokenRefresh ` -TokenEndpoint $script:JIMConnection.OAuthConfig.TokenEndpoint ` -ClientId $script:JIMConnection.OAuthConfig.ClientId ` -RefreshToken $script:JIMConnection.RefreshToken ` -Scopes $script:JIMConnection.OAuthConfig.Scopes $script:JIMConnection.AccessToken = $tokens.AccessToken $script:JIMConnection.RefreshToken = $tokens.RefreshToken $script:JIMConnection.TokenExpiresAt = $tokens.ExpiresAt Write-Verbose "Successfully refreshed access token" # Write the rotated refresh token back to the credential store so the # persisted copy stays usable in future sessions (most IdPs rotate # refresh tokens on each use). if ($script:JIMConnection.Persisted) { try { Save-JIMToken -BaseUrl $script:JIMConnection.Url -RefreshToken $tokens.RefreshToken | Out-Null } catch { Write-Verbose "Failed to persist refreshed token: $_" } } } catch { throw "Access token expired and refresh failed. Please run Connect-JIM again to re-authenticate. Error: $_" } } else { throw "Access token expired and no refresh token available. Please run Connect-JIM again to re-authenticate." } } function Invoke-JIMApiRequest { <# .SYNOPSIS Executes a single API request with authentication and error handling. For OAuth connections, automatically retries once on 401 after refreshing the token. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Endpoint, [string]$Method = 'GET', [object]$Body, [string]$ContentType = 'application/json', [switch]$IsRetry ) # Build the full URI $uri = "$($script:JIMConnection.Url.TrimEnd('/'))$Endpoint" Write-Debug "Invoking JIM API: $Method $uri" # Build request parameters with appropriate authentication header $headers = @{ 'Accept' = 'application/json' } if ($script:JIMConnection.AuthMethod -eq 'ApiKey') { $headers['X-API-Key'] = $script:JIMConnection.ApiKey } elseif ($script:JIMConnection.AuthMethod -eq 'OAuth') { $headers['Authorization'] = "Bearer $($script:JIMConnection.AccessToken)" } else { throw "Unknown authentication method: $($script:JIMConnection.AuthMethod)" } $params = @{ Uri = $uri Method = $Method ContentType = $ContentType Headers = $headers } # Add body if provided if ($Body) { if ($Body -is [string]) { $params.Body = $Body } else { $params.Body = $Body | ConvertTo-Json -Depth 10 } Write-Debug "Request body: $($params.Body)" } try { $response = Invoke-RestMethod @params -ErrorAction Stop -MaximumRedirection 0 Write-Debug "API response received successfully" return $response } catch { $statusCode = $_.Exception.Response.StatusCode.value__ $errorMessage = $_.ErrorDetails.Message if ($errorMessage) { try { $errorObj = $errorMessage | ConvertFrom-Json $errorMessage = $errorObj.message ?? $errorObj.Message ?? $errorMessage # surface per-field validation errors (e.g. invalid connected system settings) so the caller sees # which fields failed and why, not just the summary message $validationErrors = $errorObj.validationErrors ?? $errorObj.ValidationErrors if ($validationErrors) { $details = foreach ($field in $validationErrors.PSObject.Properties) { foreach ($fieldMessage in $field.Value) { " - $($field.Name): $fieldMessage" } } if ($details) { $errorMessage = "$errorMessage`n$($details -join "`n")" } } } catch { # Keep original error message if JSON parsing fails } } switch ($statusCode) { 401 { # For OAuth: attempt a reactive token refresh and retry once if ($script:JIMConnection.AuthMethod -eq 'OAuth' -and -not $IsRetry) { try { Invoke-TokenRefresh -Reason "Server rejected token (401), attempting refresh" # Retry the request once with the new token return Invoke-JIMApiRequest -Endpoint $Endpoint -Method $Method -Body $Body -ContentType $ContentType -IsRetry } catch { throw "Authentication failed after token refresh. Please run Connect-JIM to re-authenticate. Error: $_" } } elseif ($script:JIMConnection.AuthMethod -eq 'OAuth') { throw "Authentication failed. Token refresh was already attempted. Please run Connect-JIM to re-authenticate." } else { throw "Authentication failed. Your API key may be invalid or expired. Use Connect-JIM to reconnect." } } 403 { throw "Access denied. You are authenticated but not authorised to perform this operation. This usually means your identity has not been provisioned in JIM. Identities are created when you are synchronised into JIM from a connected system, or provisioned by an administrator. Contact your JIM administrator if this is unexpected." } 404 { throw "Resource not found: $errorMessage" } default { throw "JIM API error ($statusCode): $errorMessage" } } } } |