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