lib/Classes/Public/TMSession.ps1


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()

    # 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
    [Hashtable]$DataCache = @{}

    # Holds the various tokens used to authenticate with TM
    hidden [TMSessionAuthToken]$AuthTokens = [TMSessionAuthToken]::new()

    # A timer which will fire and event every 10 minutes to check if the bearer token needs to be refreshed
    hidden [Timers.Timer]$TokenRefreshTimer = [Timers.Timer]::new()

    # The number of seconds before the bearer token expires when the refresh should occur (Default: 1 hour)
    hidden [Int32]$TokenRefreshLeadTime = 3600

    # Should PowerShell ignore the SSL Cert on the TM Server?
    [Boolean]$AllowInsecureSSL = $false

    #endregion Non-Static Properties

    #region Constructors

    TMSession() {
        $this.addPublicMembers()
        $this.tokenRefreshEventSetup()
    }

    TMSession([String]$name) {
        $this.Name = $name
        $this.addPublicMembers()
        $this.tokenRefreshEventSetup()
    }

    TMSession([String]$name, [String]$server, [Boolean]$allowInsecureSSL) {
        $this.Name = $name
        $this.TMServer = [TMSession]::FormatServerUrl($server)
        $this.AllowInsecureSSL = $allowInsecureSSL
        $this.addPublicMembers()
        $this.tokenRefreshEventSetup()
    }

    # Constructor used by Import-TMCActionRequest
    TMSession([PSObject]$actionrequest) {
        $this.addPublicMembers()
        $this.tokenRefreshEventSetup()

        # Assign Values from the passed ActionRequest
        $this.TMServer = [TMSession]::FormatServerUrl($actionRequest.options.callback.siteUrl)
        $Version = [Version]::new()
        if ([Version]::TryParse(($actionrequest.tmUserSession.tmVersion -split '-')[0], [ref]$Version)) {
            $this.TMVersion = $Version
        }
        $this.UserContext = [TMSessionUserContext]::new($actionrequest.tmUserSession.userContext)

        # Extract all of the Auth tokens and apply them
        $this.AuthTokens.JSessionId = $actionrequest.tmUserSession.jsessionid
        $this.AuthTokens.BearerToken = $actionrequest.options.callback.token
        $this.AuthTokens.RefreshToken = $actionrequest.options.callback.refreshToken
        $this.AuthTokens.ExpirationSeconds = $actionrequest.options.callback.expirationSeconds
        $this.AuthTokens.CsrfHeaderName = $actionrequest.tmUserSession.csrf.tokenHeaderName
        $this.AuthTokens.CsrfToken = $actionrequest.tmUserSession.csrf.token
        $this.AuthTokens.PublicKey = $actionrequest.publicRSAKey
        $this.ApplyHeaderTokens()
    }

    #endregion Constructors

    #region Non-Static Methods

    <#
        Summary:
            Takes the items from the AuthTokens 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.AuthTokens.JSessionId)) {
            $JSessionCookie = [Net.Cookie]::new()
            $JSessionCookie.Name = "JSESSIONID"
            $JSessionCookie.Value = $this.AuthTokens.JSessionId
            $JSessionCookie.Domain = $this.TMServer
            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 Bearer Token header
        if (-not [String]::IsNullOrWhiteSpace($this.AuthTokens.BearerToken)) {
            $this.TMRestSession.Headers.Authorization = "Bearer $($this.AuthTokens.BearerToken)"
        }

        # Add the CSRF header
        if (-not [String]::IsNullOrWhiteSpace($this.AuthTokens.CsrfToken) -and -not [String]::IsNullOrWhiteSpace($this.AuthTokens.CsrfHeaderName)) {
            $this.TMWebSession.Headers."$($this.AuthTokens.CsrfHeaderName)" = $this.AuthTokens.CsrfToken
            $this.TMRestSession.Headers."$($this.AuthTokens.CsrfHeaderName)" = $this.AuthTokens.CsrfToken
        }
    }

    <#
        Summary:
            Parses the response from the login endpoints to collect and format its data into the class instance
        Params:
            Response - The response object returned from the login endpoints
            Endpoint - The login endpoint that the request was sent to (WS or REST)
        Outputs:
            None
    #>

    [void]ParseLoginResponse([Object]$Response, [String]$Endpoint) {
        # Convert the response content if necessary
        if ($Response -is [Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject]) {
            $Response = $Response.Content | ConvertFrom-Json -Depth 10
        }

        switch ($Endpoint) {
            'WS' {
                # Add the necessary properties from the web service login response to the class instance
                $this.UserContext = [TMSessionUserContext]::new($Response.userContext)
                $this.AuthTokens.CsrfHeaderName = $Response.csrf.tokenHeaderName ?? 'X-CSRF-TOKEN'
                $this.AuthTokens.CsrfToken = $Response.csrf.token
                $this.AuthTokens.SsoToken = $Response.reporting.ssoToken
                $this.AuthTokens.JSessionId = ($this.TMWebSession.Cookies.GetAllCookies() | Where-Object Name -eq JSESSIONID | Select-Object -First 1).Value
            }

            'REST' {
                # Add the necessary properties from the REST API login response to the class instance
                $this.AuthTokens.BearerToken = $Response.access_token
                $this.AuthTokens.RefreshToken = $Response.refresh_token
                $this.AuthTokens.ExpirationSeconds = $Response.expires_in
                $this.AuthTokens.GrantedDate = Get-Date
            }
        }

        $this.ApplyHeaderTokens()
    }

    <#
        Summary:
            Requests a new bearer token from TM
        Params:
            None
        Outputs:
            None
    #>

    [void]RefreshBearerToken() {
        $this | Update-TMSessionToken
    }

    #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 {}

        # Create a new event handler for the timer 'Elapsed' event
        $EventSplat = @{
            InputObject      = $this.TokenRefreshTimer
            EventName        = 'Elapsed'
            SourceIdentifier = $EventId
            MessageData      = @{ this = $this }
            Action           = {
                if ($Event.MessageData.this.ShouldRefreshBearerToken) {
                    $Event.MessageData.this.RefreshBearerToken()
                }
            }
        }
        Register-ObjectEvent @EventSplat

        # Set the timer to tick every 10 minutes
        $this.TokenRefreshTimer.Interval = 600000
        $this.TokenRefreshTimer.Start()
    }

    <#
        Summary:
            Adds members with calculated get and/or set methods
        Params:
            None
        Outputs:
            None
    #>

    hidden [void]addPublicMembers() {
        # Add a read-only calculated ShouldRefreshBearerToken property
        $this.PSObject.Properties.Add(
            [PSScriptProperty]::new(
                'ShouldRefreshBearerToken',
                { # get
                    return (((Get-Date) - $this.AuthTokens.GrantedDate).TotalSeconds -ge ($this.AuthTokens.ExpirationSeconds - $TokenRefreshLeadTime))
                }
            )
        )
    }

    #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?://', '')
    }

    #endregion Static Methods

}


class TMSessionAuthToken {

    #region Non-Static Properties

    # The JSessionID cookie provided by the web service login response
    [String]$JSessionId

    # The name of the CSRF header
    [String]$CsrfHeaderName = 'X-CSRF-TOKEN'

    # The CSRF token provided by the web service login response
    [String]$CsrfToken

    # The access token provided by the REST API login response
    [String]$BearerToken

    # The refresh token provided by the REST API login response. Will be used to get a new access token before expiration
    [String]$RefreshToken

    # The SSO token provided by the web service login response
    [String]$SsoToken

    # TM's public key
    [String]$PublicKey

    # The number of seconds that the REST access token is valid for
    [Int32]$ExpirationSeconds = [Int32]::MaxValue

    # The date/time the REST access token was granted
    [Nullable[DateTime]]$GrantedDate = (Get-Date)

    #endregion Non-Static Properties

    #region Constructors

    TMSessionAuthToken() {
        $this.addPublicMembers()
    }

    #endregion Constructors

    #region Private Methods

    <#
        Summary:
            Adds members with calculated get and/or set methods
        Params:
            None
        Outputs:
            None
    #>

    hidden [void]addPublicMembers() {
        # Add a read-only calculated ExpirationDate property
        $this.PSObject.Properties.Add(
            [PSScriptProperty]::new(
                'ExpirationDate',
                { # get
                    $this.GrantedDate.AddSeconds($this.ExpirationSeconds)
                }
            )
        )
    }
    #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

}