Public/Authentication/Connect-CWM.ps1
|
<#
.SYNOPSIS Establishes a connection to the ConnectWise Manage API. .DESCRIPTION Authenticates and establishes a connection to the ConnectWise Manage API server. Supports three authentication methods: - API Key authentication (recommended for automation) - Cookie-based authentication (username/password credentials) - Member impersonation (API keys plus -MemberID: every request is made as that member) Connection information is stored in the script-scoped $CWMServerConnection variable and used by all subsequent API calls. The connection persists for the duration of the PowerShell session unless explicitly disconnected with Disconnect-CWM. The connection is only stored once it has been validated. If any step fails (server lookup, impersonation token, cookie login or validation), nothing from the failed attempt is kept. A previous connection to the same server and company stays in place (so a transient failure during -Force does not disconnect you); a previous connection to a different server or company is cleared, and other commands fail until Connect-CWM succeeds. The function validates the connection by retrieving system information. The server's current API version is read from that response and sent in the Accept header of every later request (application/vnd.connectwise.com+json; version=X.Y), so the module always uses the latest API the server offers. The API version cannot be chosen. .PARAMETER Server The ConnectWise Manage server hostname. Examples: 'na.myconnectwise.net', 'api-aus.myconnectwise.net', 'connectwise.company.com' Pass only the hostname, optionally with a port ('cw.company.com:8443'). A leading scheme (https://) and everything from the first '/' on are stripped, so a URL pasted from the browser ('https://na.myconnectwise.net/v4_6_release/ConnectWise.aspx?locale=en_US#startscreen') works. Requests always use https. A host part with user info, a query, a fragment or a backslash (the characters '@', '?', '#' or '\' before the first '/') is rejected. .PARAMETER Company The ConnectWise Manage company identifier (company name/ID used during login). This is the company name shown on the login screen. .PARAMETER PubKey The public key component of API key authentication. Used with -PrivateKey for API Key or Impersonation authentication methods. API keys can be generated in ConnectWise Manage under: System > Members > API Keys (or API Members) .PARAMETER PrivateKey The private key component of API key authentication. Used with -PubKey for API Key or Impersonation authentication methods. Keep this value secure - treat it like a password. .PARAMETER ClientID The unique identifier for your application or integration, sent in the ClientID header. Register your application at: https://developer.connectwise.com/ClientID (requires a ConnectWise Developer login) .PARAMETER Credentials PSCredential object containing username and password. Used exclusively for Cookie authentication method. Create with: Get-Credential or [PSCredential]::new() .PARAMETER MemberID The identifier of the member to impersonate, e.g. 'jsmith'. Used with -PubKey and -PrivateKey. Connect-CWM uses the API keys to request a token for this member (POST /system/members/{MemberID}/tokens) and then sends EVERY request as that member: records are created and changed in the member's name, and the member's security roles decide what is allowed. The connection expires when the token expires; Connect-CWM then connects again. If the token cannot be issued, Connect-CWM fails and no connection is stored. Changed in 0.5.0: version 0.4.16 accepted -MemberID but silently connected with the API key instead, so requests ran as the API member. Scripts that pass -MemberID now really act as that member. .PARAMETER Force Forces a new connection even if a valid cached connection exists. By default, a connection to the same server and company, with the same sign-in method (API key, impersonation of the same member, or cookie), is reused without contacting the server. A different server, company, sign-in method or impersonated member always connects again, with or without -Force. Use -Force to refresh the connection or to switch API keys or credentials on the same server and company. .PARAMETER BasePath The base API path on the server. Default: 'apis/3.0' Only change this if using a non-standard API endpoint configuration. .PARAMETER AllowInsecureRedirect Compatibility workaround for ConnectWise-hosted (cloud) instances on PowerShell 7.4 and later. Some ConnectWise-hosted servers answer create/update requests with an http:// Location header. PowerShell 7.4+ treats any such header as an insecure redirection and throws "Cannot follow an insecure redirection by default", typically after the server has already processed the request (so the record may exist even though an error was shown). When this switch is set, create/update/delete requests suppress that check AND stop following redirects entirely (-MaximumRedirection 0). A real redirect is therefore never followed, so requests and session cookies are never sent over plain http; instead a genuine redirect response is reported as an error. Read (GET) requests are unaffected. Has no effect on Windows PowerShell 5.1 or PowerShell 7.0-7.3, which do not perform this check. Can be applied to an existing connection without -Force. .EXAMPLE # API Key authentication (recommended for automation) $Server = 'na.myconnectwise.net' $Company = 'MyCompany' $PubKey = 'abcdefgh12345678' $PrivateKey = 'ijklmnop87654321' $ClientID = 'MyPowerShellScript' Connect-CWM -Server $Server -Company $Company -PubKey $PubKey -PrivateKey $PrivateKey -ClientID $ClientID Connects using API key authentication. This is the recommended method for scripts and automation. .EXAMPLE # Cookie authentication with username/password $Server = 'na.myconnectwise.net' $Company = 'MyCompany' $Credentials = Get-Credential $ClientID = 'MyPowerShellScript' Connect-CWM -Server $Server -Company $Company -Credentials $Credentials -ClientID $ClientID Connects using traditional username/password credentials stored in a PSCredential object. .EXAMPLE # Member impersonation $Server = 'na.myconnectwise.net' $Company = 'MyCompany' $PubKey = 'abcdefgh12345678' $PrivateKey = 'ijklmnop87654321' $MemberID = 'jsmith' $ClientID = 'MyPowerShellScript' Connect-CWM -Server $Server -Company $Company -PubKey $PubKey -PrivateKey $PrivateKey -MemberID $MemberID -ClientID $ClientID Authenticates with API key and impersonates a specific member. Useful for service accounts that need to perform actions as specific users. .EXAMPLE # Force reconnection Connect-CWM -Server 'na.myconnectwise.net' -Company 'MyCompany' -PubKey $PubKey -PrivateKey $PrivateKey -ClientID $ClientID -Force Forces a new connection even if one exists, for example to switch accounts. .EXAMPLE # Secure credential handling $SecureParams = @{ Server = 'na.myconnectwise.net' Company = 'MyCompany' PubKey = $env:CWM_PUBKEY PrivateKey = $env:CWM_PRIVATEKEY ClientID = 'MyScript' } Connect-CWM @SecureParams Uses environment variables to avoid hardcoding credentials in scripts. .EXAMPLE Connect-CWM -Server 'na.myconnectwise.net' -Company 'MyCompany' -PubKey $Pub -PrivateKey $Priv -ClientID $ClientID -AllowInsecureRedirect Connects to a ConnectWise-hosted instance and enables the workaround for the PowerShell 7.4+ "Cannot follow an insecure redirection" error on New-/Update-CWM* commands. .NOTES Author: Chris Taylor Connection Lifecycle: - Connections persist for the PowerShell session duration - Only impersonation connections expire (when the impersonation token expires); API key and cookie connections are reused until Disconnect-CWM or -Force - A cached connection is reused only for the same server, company, sign-in method, impersonated member, ClientID and BasePath; connecting to another tenant replaces it - Cookie-session cookies are marked Secure, so they are only sent over https - Use Disconnect-CWM to explicitly clear connection - Use -Force to refresh an existing connection Security Considerations: - API keys are preferred over username/password for automation - Store credentials securely (environment variables, secure storage, key vaults) - Never commit credentials to source control - Regularly rotate API keys per security policies ClientID: - Mandatory parameter; sent as the ClientID header on every request - Register at: https://developer.connectwise.com/ClientID (requires a ConnectWise Developer login) .LINK https://developer.connectwise.com/manage/rest .LINK https://developer.connectwise.com/ClientID .LINK Disconnect-CWM .LINK Get-CWMSystemInfo .OUTPUTS None. Connection information is stored in script-scoped variable $CWMServerConnection. .INPUTS None. This function does not accept pipeline input. #> function Connect-CWM { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Server, [Parameter(Mandatory = $true)] [string]$Company, [Parameter(ParameterSetName = 'API Key', Mandatory = $True)] [Parameter(ParameterSetName = 'Impersonation', Mandatory = $True)] [string]$PubKey, [Parameter(ParameterSetName = 'API Key', Mandatory = $True)] [Parameter(ParameterSetName = 'Impersonation', Mandatory = $True)] [string]$PrivateKey, [Parameter(Mandatory = $true)] [string]$ClientID, [Parameter(ParameterSetName = 'Cookie', Mandatory = $True)] [pscredential]$Credentials, [Parameter(ParameterSetName = 'Impersonation', Mandatory = $True)] [string]$MemberID, [switch]$Force, [string]$BasePath = 'apis/3.0', [switch]$AllowInsecureRedirect ) if ($AllowInsecureRedirect -and !$script:CWMSupportsInsecureRedirect) { Write-Verbose 'AllowInsecureRedirect has no effect on this PowerShell version (requires 7.4+).' } # Validate server: a host name with an optional port. A leading http(s):// and everything from the first '/' # on (path, query, fragment of a pasted URL) are stripped, as in 0.4.16. In the remaining authority, anything # that could change which host the credentials are sent to (user info, query, fragment, '\') is rejected. $ServerInput = $Server $Server = (($Server.Trim() -replace '^https?://', '') -split '/')[0] $ServerUri = $null if ($Server -match '[@#?\\\s]' -or $Server -notmatch '^[A-Za-z0-9_]([A-Za-z0-9_.-]*[A-Za-z0-9_])?(:\d{1,5})?$' -or ![uri]::TryCreate("https://$Server", [System.UriKind]::Absolute, [ref]$ServerUri) -or $ServerUri.UserInfo -or $ServerUri.PathAndQuery -ne '/' -or $ServerUri.Fragment) { Write-Error "Invalid -Server '$ServerInput'. Pass the host name of the ConnectWise Manage server, e.g. 'na.myconnectwise.net' or 'cw.company.com:8443'." -ErrorAction Stop } if ($MemberID) { $ConnectionMethod = 'Impersonation' } elseif ($Credentials) { $ConnectionMethod = 'Cookie' } else { $ConnectionMethod = 'Key' } # Reuse the cached connection only for the same server, company and sign-in method (and impersonated member). # Anything else connects again, as if -Force was passed. $Cached = $script:CWMServerConnection $CachedIsValid = $Cached -and (!$Cached.Expiration -or $Cached.Expiration -gt $(Get-Date)) $CachedIsSameIdentity = $Cached -and $Cached.Server -eq $Server -and $Cached.Company -eq $Company -and $Cached.ConnectionMethod -eq $ConnectionMethod -and "$($Cached.MemberID)" -eq "$MemberID" if ($CachedIsValid -and !$Force) { # A changed -ClientID or -BasePath also connects again, so the new values are used. if ($CachedIsSameIdentity -and $Cached.Headers.ClientID -eq $ClientID -and $Cached.BasePath -eq $BasePath) { Write-Verbose 'Using cached Authentication information.' if ($PSBoundParameters.ContainsKey('AllowInsecureRedirect')) { $Cached.AllowInsecureRedirect = [bool]$AllowInsecureRedirect } return } Write-Verbose 'The cached connection is for a different server, company, sign-in method, ClientID or BasePath. Connecting again.' } $EscapedCompany = [uri]::EscapeDataString($Company) $Connected = $false # A failed reconnect as the same identity (same server, company, sign-in method and member; e.g. -Force # hitting a transient error) keeps the previous, unexpired connection. Any other failed connect clears it, # so callers never silently continue under a different identity or tenant. $Previous = $null $PreviousSession = $null if ($CachedIsValid -and $CachedIsSameIdentity) { $Previous = $Cached $PreviousSession = $script:CWMSession } try { # The previous connection is replaced. On any failure the finally block puts back the previous # same-tenant connection (or nothing), so a partly built connection is never left behind. $script:CWMServerConnection = $null $script:CWMSession = $null # Codebase discovery runs before there is a connection, so it calls Invoke-RestMethod directly $CompanyInfoArguments = @{ Uri = "https://$($Server)/login/companyinfo/$($EscapedCompany)" Headers = @{ ClientID = $ClientID } UserAgent = $script:CWMUserAgent UseBasicParsing = $true ErrorAction = 'Stop' } try { $CompanyInfo = Invoke-RestMethod @CompanyInfoArguments } catch { Write-Error $_ -ErrorAction Stop } # Every URL is built on the codebase; without it the company was not found on this server if (!$CompanyInfo.Codebase) { Write-Error "The server did not return a codebase for company '$Company'. Check -Server and -Company." -ErrorAction Stop } $Headers = @{ ClientID = $ClientID 'Cache-Control' = 'no-cache' } $Connection = @{ Server = $Server Company = $Company Headers = $Headers Session = $null Expiration = $null ConnectionMethod = $ConnectionMethod Codebase = $CompanyInfo.Codebase BasePath = $BasePath } # The login and impersonation requests below go through Invoke-CWMWebRequest, which needs a connection $script:CWMServerConnection = $Connection # Impersonation. Checked before plain API key auth, which also matches when PubKey and PrivateKey are passed. if ($ConnectionMethod -eq 'Impersonation') { Write-Verbose "Impersonating, $MemberID" $Connection.MemberID = $MemberID # The token request is authenticated with the API member's own keys $TokenHeaders = $Headers.Clone() $TokenHeaders.Authorization = ConvertTo-CWMBasicAuth -Company $Company -PublicKey $PubKey -PrivateKey $PrivateKey $TokenHeaders.'x-cw-usertype' = 'member' $WebRequestArguments = @{ Method = 'Post' # The server's codebase and the connection's base path, like every other request Uri = New-CWMUrl -Endpoint "/system/members/$([uri]::EscapeDataString($MemberID))/tokens" Body = @{ memberIdentifier = $MemberID } | ConvertTo-Json ContentType = 'application/json; charset=utf-8' Headers = $TokenHeaders } $Result = Invoke-CWMWebRequest -Arguments $WebRequestArguments if ($Result.content) { $Result = ConvertFrom-CWMJson $Result.Content } if (!$Result.publicKey -or !$Result.privateKey) { Write-Error "Issue getting Auth Token for Impersonated user, $MemberID" return } # Create auth header for Impersonated user if ($Result.expiration) { $Connection.Expiration = [datetime]$Result.expiration } $Headers.Authorization = ConvertTo-CWMBasicAuth -Company $Company -PublicKey $Result.publicKey -PrivateKey $Result.privateKey } # API key elseif ($ConnectionMethod -eq 'Key') { Write-Verbose 'Using API Key authentication' $Headers.Authorization = ConvertTo-CWMBasicAuth -Company $Company -PublicKey $PubKey -PrivateKey $PrivateKey } # Cookies, yummy else { Write-Verbose 'Using Cookie authentication' $Body = @{ CompanyName = $Company UserName = $Credentials.UserName Password = $Credentials.GetNetworkCredential().Password } # The login response's cookies are stored in this session $Session = New-Object Microsoft.PowerShell.Commands.WebRequestSession $WebRequestArguments = @{ Uri = (Join-Url (Join-Url "https://$($Server)" $Connection.Codebase) 'login/login.aspx?response=json') Method = 'Post' Body = $Body WebSession = $Session } $CookieResponse = Invoke-CWMWebRequest -Arguments $WebRequestArguments $CookieResult = $null if ($CookieResponse.Content) { $CookieResult = ConvertFrom-CWMJson $CookieResponse.Content } if (!$CookieResult.Success) { Write-Error 'There was an error obtain the MemberHash' if ($CookieResult) { Write-Error ($CookieResult | Out-String) } return } # Seems cookies don't get put in session any more, add them. Secure: only ever sent over https. $Cookies = [ordered]@{ companyName = $Company memberHash = $CookieResult.Hash MemberID = $Credentials.UserName MemberContext = 'web' } foreach ($Name in $Cookies.Keys) { $Cookie = New-Object System.Net.Cookie $Cookie.Name = $Name $Cookie.Value = $Cookies[$Name] $Cookie.Domain = $ServerUri.Host $Cookie.Secure = $true $Session.Cookies.Add($Cookie) } $Connection.Session = $Session } # Set only now; the login/impersonation requests above run without it $Connection.AllowInsecureRedirect = [bool]$AllowInsecureRedirect # No version yet: it is read from the system info below $Connection.Headers.Accept = 'application/vnd.connectwise.com+json' Write-Verbose 'Validating authentication' $Info = Get-CWMSystemInfo if (!$Info) { Write-Warning 'Authentication failed. The new connection was not stored.' return } # Always use the server's current API version [version]$ServerVersion = $Info.version.trim('v') $Connection.Version = "$($ServerVersion.Major).$($ServerVersion.Minor)" $Connection.Headers.Accept = "application/vnd.connectwise.com+json; version=$($Connection.Version)" $script:CWMSession = $Connection.Session $Connected = $true } finally { if (!$Connected) { $script:CWMServerConnection = $Previous $script:CWMSession = $PreviousSession } } Write-Verbose 'Connection successful.' Write-Verbose '$CWMServerConnection, variable initialized.' } |