Classes/Public/TMSession.ps1
#region Classes class TMSession { #region Non-Static Properties # A Name parameter to identify the session in other functions [String]$Name = 'Default' # TM Server hostname [String]$TMServer # TMVersion drives the selection of compatible APIs to use [Version]$TMVersion = [Version]::new() # Logged in TM User's Context [TMSessionUserContext]$UserContext = [TMSessionUserContext]::new() # Logged in TM User's details [TMUserAccount]$UserAccount = [TMUserAccount]::new() # TMWebSession Variable. Maintained by the Invoke-WebRequest function's capability [Microsoft.PowerShell.Commands.WebRequestSession]$TMWebSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() # TMRestSession Variable. Maintained by the Invoke-RestMethod function's capability [Microsoft.PowerShell.Commands.WebRequestSession]$TMRestSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() # Tracks non-changing items to reduce HTTP lookups, and increase speed of scripts. # DataCache is expected to be a k/v pair, where the V could be another k/v pair, # However, it's implementation will be more of the nature to hold the list of object calls from the API # like 'credentials' = @(@{...},@{...}); 'actions' = @(@{...},@{...}) # Get-TM* functions will cache unless a -NoCache switch is provided [TMSessionDataCache]$DataCache = [TMSessionDataCache]::new() # Holds the various tokens used to authenticate with TM hidden [TMSessionAuthentication]$Authentication = [TMSessionAuthentication]::new() # Should PowerShell ignore the SSL Cert on the TM Server? [Boolean]$AllowInsecureSSL = $false # The origin of the class instance. Will be set to TMC when an action request is imported [TMSessionOrigin]$Origin = [TMSessionOrigin]::PSSession #endregion Non-Static Properties #region Constructors TMSession() { $this.tokenRefreshEventSetup() } TMSession([String]$name) { $this.Name = $name $this.tokenRefreshEventSetup() } TMSession([String]$name, [String]$server, [Boolean]$allowInsecureSSL) { $this.Name = $name $this.TMServer = [TMSession]::FormatServerUrl($server) $this.AllowInsecureSSL = $allowInsecureSSL $this.tokenRefreshEventSetup() } # Constructor used by Import-TMCActionRequest TMSession([PSObject]$actionrequest) { # Parse the Action Request for its callback data $this.ParseCallbackData($actionrequest, [TMCallbackDataType]::ActionRequest) # Apply the headers from the parsed auth information $this.ApplyHeaderTokens() # Set the origin of this class instance $this.Origin = [TMSessionOrigin]::TMC # Check if the token from TMC is still good if ($this.Authentication.OAuth.ShouldRefreshToken) { $this.RefreshAccessToken() } # Set up the REST API access token refresh timer $this.tokenRefreshEventSetup() } #endregion Constructors #region Non-Static Methods <# Summary: Takes the items from the Authentication object and applies them to the appropriate headers/cookies in the web sessions Params: None Outputs: None #> [void]ApplyHeaderTokens() { # Add the JSESSIONID Cookie if (-not [String]::IsNullOrWhiteSpace($this.Authentication.JSessionId)) { $JSessionCookie = [Net.Cookie]::new() $JSessionCookie.Name = "JSESSIONID" $JSessionCookie.Value = $this.Authentication.JSessionId $JSessionCookie.Domain = $this.TMServer $JSessionCookie.Secure = $true $JSessionCookie.Path = '/tdstm' $JSessionCookie.HttpOnly = $true if (-not ($this.TMWebSession.Cookies.GetAllCookies() | Where-Object Name -eq 'JSESSIONID')) { $this.TMWebSession.Cookies.Add($JSessionCookie) } if (-not ($this.TMRestSession.Cookies.GetAllCookies() | Where-Object Name -eq 'JSESSIONID')) { $this.TMRestSession.Cookies.Add($JSessionCookie) } } # Add the Access Token header if (-not [String]::IsNullOrWhiteSpace($this.Authentication.OAuth.AccessToken)) { $this.TMRestSession.Headers.Authorization = $this.Authentication.OAuth.TokenType + " " + $($this.Authentication.OAuth.AccessToken) } # Add the CSRF header if (-not [String]::IsNullOrWhiteSpace($this.Authentication.CsrfToken) -and -not [String]::IsNullOrWhiteSpace($this.Authentication.CsrfHeaderName)) { $this.TMWebSession.Headers."$($this.Authentication.CsrfHeaderName)" = $this.Authentication.CsrfToken $this.TMRestSession.Headers."$($this.Authentication.CsrfHeaderName)" = $this.Authentication.CsrfToken } } <# Summary: Parses the response from the login endpoints or Action Request to collect and format its data into the class instance Params: Data - The response object returned from the login endpoints Type - The source of the callback data. WS/REST login responses or an Action Request from TM Outputs: None #> [void]ParseCallbackData([Object]$CallbackData, [TMCallbackDataType]$Type) { switch ($Type) { ([TMCallbackDataType]::WebService) { # Convert the response content if necessary if ($CallbackData -is [Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject]) { $CallbackData = $CallbackData.Content | ConvertFrom-Json -Depth 10 } # Add the necessary properties from the web service login response to the class instance $this.UserContext = [TMSessionUserContext]::new($CallbackData.userContext) $this.Authentication.CsrfHeaderName = $CallbackData.csrf.tokenHeaderName ?? 'X-CSRF-TOKEN' $this.Authentication.CsrfToken = $CallbackData.csrf.token $this.Authentication.SsoToken = $CallbackData.reporting.ssoToken $this.Authentication.JSessionId = ($this.TMWebSession.Cookies.GetAllCookies() | Where-Object Name -eq JSESSIONID | Select-Object -First 1).Value } ([TMCallbackDataType]::REST) { # Add the necessary properties from the REST API login response to the class instance $this.Authentication.OAuth.AccessToken = $CallbackData.access_token $this.Authentication.OAuth.RefreshToken = $CallbackData.refresh_token $this.Authentication.OAuth.ExpirationSeconds = $CallbackData.expires_in $this.Authentication.OAuth.GrantedDate = Get-Date } ([TMCallbackDataType]::ActionRequest) { # Assign Values from the passed ActionRequest $this.TMServer = [TMSession]::FormatServerUrl($CallbackData.options.callback.siteUrl) $this.TMVersion = [TMSession]::ParseVersion($CallbackData.tmUserSession.tmVersion) # Set the session's user details if ($CallbackData.userAccount) { try { $this.UserAccount = [TMUserAccount]::new($CallbackData.userAccount) } catch { $this.UserAccount = [TMUserAccount]::new() } } if ($CallbackData.tmUserSession.userContext) { try { $this.UserContext = [TMSessionUserContext]::new($CallbackData.tmUserSession.userContext) } catch { $this.UserContext = [TMSessionUserContext]::new() } } # Extract all of the Auth tokens and apply them $this.Authentication.JSessionId = $CallbackData.tmUserSession.jsessionid $this.Authentication.OAuth.AccessToken = $CallbackData.options.callback.token $this.Authentication.OAuth.RefreshToken = $CallbackData.options.callback.refreshToken $this.Authentication.OAuth.GrantedUnixTime = $CallbackData.options.callback.grantedDate $this.Authentication.OAuth.ExpirationSeconds = $CallbackData.options.callback.expirationSeconds $this.Authentication.CsrfHeaderName = $CallbackData.tmUserSession.csrf.tokenHeaderName $this.Authentication.CsrfToken = $CallbackData.tmUserSession.csrf.token $this.Authentication.PublicKey = $CallbackData.publicRSAKey } } } <# Summary: Requests a new access token from TM Params: None Outputs: None #> [void]RefreshAccessToken() { try { if ($this.Authentication.OAuth.TokenExpired -and -not $this.UserAccount.IsLocalAccount) { throw "The token has already expired" } Write-Verbose "Refreshing Token: $($this.Authentication.OAuth.AbbreviatedAccessToken)" $this | Update-TMSessionToken } catch { $ThisError = $_ Write-Warning "Could not refresh REST API access token: $($ThisError.Exception.Message)" # Stop the timer and remove the event subscription try { $this.Authentication.OAuth.tokenRefreshTimer.Stop() Unregister-Event -SourceIdentifier "$($this.Name)_TMSession.TokenRefreshTimer.Elapsed" -Force -ErrorAction Ignore } catch { Write-Warning "Could not disable the automated token refresh: $($_.Exception.Message)" } } } <# Summary: Populates the UserAccount object by querying TM for the User's details Params: None Outputs: None #> [void]GetUserAccount() { try { $this.UserAccount = $this | Get-TMUserAccount } catch { Write-Warning "Could not get user info: $($_.Exception.Message)" } } <# Summary: Defines how the class instance will be displayed as a string Params: None Outputs: String with the class instance's Name #> [String]ToString() { return $this.Name } #endregion Non-Static Methods #region Private Methods <# Summary: Creates an event handler for the token refresh timer Params: None Outputs: None #> hidden [void]tokenRefreshEventSetup() { $EventId = $this.Name + "_TMSession.TokenRefreshTimer.Elapsed" # Remove any old event subscriptions try { Unregister-Event -SourceIdentifier $EventId -Force -ErrorAction Ignore } catch { Write-Verbose "PSScriptAnalyzer doesn't like empty catch blocks."} # Create a new event handler for the timer 'Elapsed' event $EventSplat = @{ InputObject = $this.Authentication.OAuth.tokenRefreshTimer EventName = 'Elapsed' SourceIdentifier = $EventId MessageData = @{ this = $this } Action = { if ($Event.MessageData.this.Authentication.OAuth.ShouldRefreshToken) { $Event.MessageData.this.RefreshAccessToken() } } } Register-ObjectEvent @EventSplat # Start the refresh timer $this.Authentication.OAuth.tokenRefreshTimer.Start() } #endregion Private Methods #region Static Methods <# Summary: Formats the passed URL to be compatible with TM module functions Params: Url - The URL to be formatted Outputs: The URL stripped down to just the host name #> static [String]FormatServerUrl([String]$Url) { return (([URI]$Url).Host ?? $Url -replace '/tdstm.*|https?://', '') } <# Summary: Parses the passed object representing a version into a Version object Params: Version - The object to be parsed Outputs: A [Version] object representing the passed version or 0.0.0 if parsing fails #> static [Version]ParseVersion([Object]$Version) { $ReturnVersion = [Version]::new() if ($Version -is [Version]) { $ReturnVersion = $Version } elseif ($Version -is [String]) { if (-not [Version]::TryParse($Version, [ref]$ReturnVersion)) { if ($Version -match '(?<SemVer>(?:\d+)\.(?:\d+)\.(?:\d+)(?:\.\d+)?)') { if (-not [Version]::TryParse($Matches.SemVer, [ref]$ReturnVersion)) { $ReturnVersion = [Version]::new() } } else { $ReturnVersion = [Version]::new() } } } return $ReturnVersion } #endregion Static Methods } class TMSessionAuthentication { #region Non-Static Properties # The JSessionID cookie provided by the web service login response [String]$JSessionId # Holds the information about the OAuth tokens needed for the REST API [TMSessionOAuth]$OAuth = [TMSessionOAuth]::new() # The name of the CSRF header [String]$CsrfHeaderName = 'X-CSRF-TOKEN' # The CSRF token provided by the web service login response [String]$CsrfToken # The SSO token provided by the web service login response [String]$SsoToken # TM's public key [String]$PublicKey #endregion Non-Static Properties #region Constructors TMSessionAuthentication() {} #endregion Constructors } class TMSessionOAuth { #region Non-Static Properties # The access token provided by the REST API login response [String]$AccessToken # The refresh token provided by the REST API login response. Will be used to get a new access token before expiration [String]$RefreshToken # The type of OAuth access token granted by TM [String]$TokenType = 'Bearer' # The percentage of the access token's expiration seconds that will elapse before the token is refreshed [ValidateRange(1, 99)] [Int32]$TokenAgeRefreshThreshold = 75 #endregion Non-Static Properties #region Private Fields # A timer which will fire and event every 10 minutes to check if the access token needs to be refreshed hidden [Timers.Timer]$tokenRefreshTimer = [Timers.Timer]::new() # The number of seconds that the REST access token is valid for hidden [Int32]$_expirationSeconds # The date/time the REST access token was granted hidden [Nullable[DateTime]]$_grantedDate # The Unix Time milliseconds when the REST access token was granted hidden [Int64]$_grantedUnixTime #endregion Private Fields #region Constructors TMSessionOAuth() { $this.addPublicMembers() $this.ExpirationSeconds = 14400 $this.GrantedDate = Get-Date } #endregion Constructors #region Static Methods <# Summary: Abbreviates a given auth token string to a shorter string to be displayed in functions like Write-Host Params: AuthToken - The full auth token string Outputs: A 60 character string representing the first and last few characters in the string #> static [String]Abbreviate([String]$AuthToken) { return $AuthToken.Length -le 60 ? $AuthToken : ( $AuthToken.Substring(0, 28) + "..." + $AuthToken.Substring($AuthToken.Length - 28, 28) ) } #endregion Static Methods #region Private Methods <# Summary: Adds members with calculated get and/or set methods Params: None Outputs: None #> hidden [void]addPublicMembers() { # public readonly DateTime ExpirationDate $this.PSObject.Properties.Add( [PSScriptProperty]::new( 'ExpirationDate', { # get return $this.GrantedDate.AddSeconds($this._expirationSeconds) } ) ) # public readonly Boolean ShouldRefreshToken $this.PSObject.Properties.Add( [PSScriptProperty]::new( 'ShouldRefreshToken', { # get return (((Get-Date) - $this._grantedDate).TotalSeconds -ge ($this._expirationSeconds * ($this.TokenAgeRefreshThreshold / 100))) } ) ) # public readonly Boolean TokenExpired $this.PSObject.Properties.Add( [PSScriptProperty]::new( 'TokenExpired', { # get return (Get-Date) -ge $this.ExpirationDate } ) ) # public readonly String AbbreviatedAccessToken $this.PSObject.Properties.Add( [PSScriptProperty]::new( 'AbbreviatedAccessToken', { # get return [TMSessionOAuth]::Abbreviate($this.AccessToken) } ) ) # public readonly String AbbreviatedRefreshToken $this.PSObject.Properties.Add( [PSScriptProperty]::new( 'AbbreviatedRefreshToken', { # get return [TMSessionOAuth]::Abbreviate($this.RefreshToken) } ) ) # public Int32 ExpirationSeconds $this.PSObject.Properties.Add( [PSScriptProperty]::new( 'ExpirationSeconds', { # get return $this._expirationSeconds }, { # set param ([Int32]$value) $this._expirationSeconds = $value # Modify the refresh timing values based on the token's expiration seconds $this.tokenRefreshTimer.Interval = [Math]::Ceiling(($value / 24) * 1000) } ) ) # public DateTime GrantedDate $this.PSObject.Properties.Add( [PSScriptProperty]::new( 'GrantedDate', { # get return $this._grantedDate }, { # set param([Nullable[DateTime]]$value) $this._grantedDate = $value try { $this._grantedUnixTime = ([DateTimeOffset]$value).ToUnixTimeMilliseconds() } catch { Write-Verbose "PSScriptAnalyzer doesn't like empty catch blocks."} } ) ) # public Int64 GrantedDateUnix $this.PSObject.Properties.Add( [PSScriptProperty]::new( 'GrantedUnixTime', { # get return $this._grantedUnixTime }, { # set param([Int64]$value) $this._grantedUnixTime = $value try { $this._grantedDate = (Get-Date -UnixTimeSeconds ($value / 1000)) } catch { Write-Verbose "PSScriptAnalyzer doesn't like empty catch blocks."} } ) ) } #endregion Private Methods } class TMSessionUserContext { #region Non-Static Properties [TMReference]$User [TMSessionUserContextPerson]$Person [TMSessionUserContextProject]$Project [TMReference]$Event [Object]$Bundle [String]$Timezone [String]$DateFormat [Boolean]$GridFilterVisible [String[]]$AlternativeProjects [String]$DefaultLandingPage [String]$LandingPage #endregion Non-Static Properties #region Constructors TMSessionUserContext() {} TMSessionUserContext([Object]$object) { $this.User = [TMReference]::new($object.user) $this.Person = [TMSessionUserContextPerson]::new($object.person) $this.Project = [TMSessionUserContextProject]::new($object.project) $this.Event = [TMReference]::new($object.event) $this.Bundle = $object.bundle $this.Timezone = $object.timezone $this.DateFormat = $object.dateFormat $this.GridFilterVisible = $object.gridFilterVisible $this.AlternativeProjects = $object.alternativeProjects $this.DefaultLandingPage = $object.defaultLandingPage $this.LandingPage = $object.landingPage } #endregion Constructors } class TMSessionUserContextPerson { #region Non-Static Properties [Int64]$Id [String]$FirstName [String]$FullName #endregion Non-Static Properties #region Constructors TMSessionUserContextPerson() {} TMSessionUserContextPerson([Int64]$id, [String]$firstName, [String]$fullName) { $this.Id = $id $this.FirstName = $firstName $this.FullName = $fullName } TMSessionUserContextPerson([Object]$object) { $this.Id = $object.id $this.FirstName = $object.firstName $this.FullName = $object.fullName } #endregion Constructors } class TMSessionUserContextProject { #region Non-Static Properties [Int64]$Id [String]$Name [String]$Status [String]$LogoUrl [String]$Code #endregion Non-Static Properties #region Constructors TMSessionUserContextProject() {} TMSessionUserContextProject([Int64]$id, [String]$name, [String]$status, [String]$logoUrl, [String]$code) { $this.Id = $id $this.Name = $name $this.Status = $status $this.LogoUrl = $logoUrl $this.Code = $code } TMSessionUserContextProject([Object]$object) { $this.Id = $object.id $this.Name = $object.name $this.Status = $object.status $this.LogoUrl = $object.logoUrl $this.Code = $object.code } #endregion Constructors } #endregion Classes #region Enumerations enum TMSessionOrigin { PSSession TMC } enum TMCallbackDataType { WebService REST ActionRequest } #endregion Enumerations |