PSAuthClient.psm1

function Clear-WebView2Cache {
    <#
    .SYNOPSIS
    Deletes the WebView2 cache folder.
    .DESCRIPTION
    Removes PSAuthClient WebView2 user data folder (UDF) which is used to store browser data such as cookies, permissions and cached resources.
    .EXAMPLE
    Clear-WebView2Cache
    Deletes the WebView2 cache folder.
    #>

    [cmdletbinding(SupportsShouldProcess, ConfirmImpact = 'High')]param()
    if ($PSCmdlet.ShouldProcess("PSAuthClientWebview2Cache", "delete")) { 
        if ( (test-path "$env:temp\PSAuthClientWebview2Cache\") ) { Remove-Item "$env:temp\PSAuthClientWebview2Cache\" -Recurse -Force }
    }
}
function ConvertFrom-JsonWebToken {
    <#
    .SYNOPSIS
    Convert (decode) a JSON Web Token (JWT) to a PowerShell object.
 
    .DESCRIPTION
    Convert (decode) a JSON Web Token (JWT) to a PowerShell object.
 
    .PARAMETER jwtInput
    The JSON Web Token (string) to be must be in the form of a valid JWT.
 
    .EXAMPLE
    PS> ConvertFrom-JsonWebToken "ew0KICAidHlwIjogIkpXVCIsDQogICJhbGciOiAiUlMyNTYiDQp9.ew0KICAiZXhwIjogMTcwNjc4NDkyOSwNCiAgImVjaG8..."
    header : @{typ=JWT; alg=RS256}
    exp : 1706784929
    echo : Hello World!
    nbf : 1706784629
    sub : PSAuthClient
    iss : https://example.org
    jti : 27913c80-40d1-46a3-89d5-d3fb9f0d1e4e
    iat : 1706784629
    aud : PSAuthClient
    signature : OHIxRGxuaXVLTjh4eXhRZ0VWYmZ3SHNlQ29iOUFBUVRMK1dqWUpWMEVXMD0
 
    #>

    param ( 
        [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
        [ValidatePattern("^e[yJ|w0]([a-zA-Z0-9_-]+[.]){2}", Options = "None")]
        [string]$jwtInput
    )
    $response = New-Object -TypeName PSObject
    [pscustomobject]$jwt = $jwtInput -split "[.]"
    for ( $i = 0; $i -lt $jwt.count; $i++ ) {
        try { $data = ConvertFrom-Base64UrlEncoding $jwt[$i] | ConvertFrom-Json }
        catch { $data = $jwt[$i] }
        switch ( $i ) {
            0 { $response | Add-Member -NotePropertyName header -TypeName NoteProperty $data }
            1 { $response | Add-Member -NotePropertyName payload -TypeName NoteProperty $data  }
            2 { $response | Add-Member -NotePropertyName signature -TypeName NoteProperty $data  }
        }
    }
    # ...We prefer ordered output
    return $response | Select-Object header, signature -ExpandProperty payload | Select-Object header, * -ErrorAction SilentlyContinue
}
function Get-OidcDiscoveryMetadata {
    <#
    .SYNOPSIS
    Retreive OpenID Connect Discovery endpoint metadata
    .DESCRIPTION
    Retreive OpenID Connect Discovery endpoint metadata.
    .PARAMETER uri
    The URI of the OpenID Connect Discovery endpoint.
    .EXAMPLE
    PS> Get-OidcDiscoveryMetadata "https://example.org"
    Attempts to retreive OpenID Connect Discovery endpoint metadata from 'https://example.org/.well-known/openid-configuration'.
    .EXAMPLE
    PS> Get-OidcDiscoveryMetadata "https://login.microsoftonline.com/common"
    token_endpoint : https://login.microsoftonline.com/common/oauth2/token
    token_endpoint_auth_methods_supported : {client_secret_post, private_key_jwt, client_secret...}
    jwks_uri : https://login.microsoftonline.com/common/discovery/keys
    response_modes_supported : {query, fragment, form_post}
    subject_types_supported : {pairwise}
    id_token_signing_alg_values_supported : {RS256}
    response_types_supported : {code, id_token, code id_token, token id_tokenÔǪ}
    scopes_supported : {openid}
    issuer : https://sts.windows.net/{tenantid}/
    microsoft_multi_refresh_token : True
    authorization_endpoint : https://login.microsoftonline.com/common/oauth2/auth...
    device_authorization_endpoint : https://login.microsoftonline.com/common/oauth2/devi...
    http_logout_supported : True
    frontchannel_logout_supported : True
    end_session_endpoint : https://login.microsoftonline.com/common/oauth2/logo...
    claims_supported : {sub, iss, cloud_instance_name, cloud_instance_host...}
    check_session_iframe : https://login.microsoftonline.com/common/oauth2/chec...
    userinfo_endpoint : https://login.microsoftonline.com/common/openid/user...
    kerberos_endpoint : https://login.microsoftonline.com/common/kerberos
    tenant_region_scope :
    cloud_instance_name : microsoftonline.com
    cloud_graph_host_name : graph.windows.net
    msgraph_host : graph.microsoft.com
    rbac_url : https://pas.windows.net
    #>

    Param($uri)
    if ( $uri -notmatch "^http(s)?://" ) { $uri = "https://$uri" }
    if ( $uri -notmatch "[.]well-known" ) { $uri = "$uri/.well-known/openid-configuration" }
    return Invoke-RestMethod $uri -Method GET -Verbose:$false
}
function ConvertFrom-Base64UrlEncoding {
    <#
    .SYNOPSIS
    Convert a a base64url string to text.
    .DESCRIPTION
    Base64-URL-encoding is a minor variation on the typical Base64 encoding method, but uses URL-safe characters instead.
    .PARAMETER value
    The base64url encoded string to convert.
    .PARAMETER rawBytes
    Return output as byteArray instead of the string.
    .EXAMPLE
    ConvertFrom-Base64UrlEncoding -value "eyJ0eXAiOiJKV1QiLCJhbGciOiJ..."
    {"typ":"JWT","alg":"RS256","kid":"kWbkha6qs8wsTnBwiNYOhHbnAw"}
    #>

    param( 
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string]$value, 
        [Parameter (Mandatory = $false)]
        [switch]$rawBytes 
    )
    # change - back to +, and _ back to /
    $value = $value -replace "-","+" -replace "_","/"
    # determine padding
    switch ( $value.Length % 4 ) {
        0 { break }
        2 { $value += '==' }
        3 { $value += '=' }
    }
    try { 
        $byteArray = [system.convert]::FromBase64String($value)
        if ( $rawBytes ) { return $byteArray }
        else { return [System.Text.Encoding]::Default.GetString($byteArray) }
    }
    catch { throw $_ }
}
function ConvertTo-Base64UrlEncoding {
    <#
    .SYNOPSIS
    Convert a a string to base64url encoding.
    .DESCRIPTION
    Base64-URL-encoding is a minor variation on the typical Base64 encoding method, but uses URL-safe characters instead.
    .PARAMETER value
    string to convert
    .EXAMPLE
    ConvertTo-Base64UrlEncoding -value "{"typ":"JWT","alg":"RS25....}
    eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtXYmthYTZxczh3c1RuQndpaU5ZT2hIYm5BdyJ9
    #>

    param ([Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]$value)
    if ( $value.GetType().Name -eq "String" ) { $value = [System.Text.Encoding]::UTF8.GetBytes($value) }
    # change + to -, and / to _, then trim the trailing = from the end.
    return ( [System.Convert]::ToBase64String($value) -replace '\+','-' -replace '/','_' -replace '=' )
}
function Get-RandomString { 
    <#
    .SYNOPSIS
    Generate a random string.
    .DESCRIPTION
    Generate a random string of a given length, by default between 43 and 128 characters.
    .PARAMETER Length
    The length of the string to generate.
    .EXAMPLE
    Get-RandomString
    95TFIttXFdwvhW8DCXVhlHqsld62U_NlxlQe.YqdN.Hm5xs8S3.bTISQ
    #>

    param( [int]$Length = ((43..128) | get-random) )
    $charArray = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~".ToCharArray()
    if ( $PSVersionTable.PSVersion -ge "7.4.0" ) { $randomizedCharArray = for ( $i = 0; $i -lt $Length; $i++ ) { $charArray[(0..($charArray.count-1) | Get-SecureRandom)] } }
    else { $randomizedCharArray = for ( $i = 0; $i -lt $Length; $i++ ) { $charArray[(0..($charArray.count-1) | Get-Random)] } }
    return [string](-join $randomizedCharArray)
}
function Get-UnixTime {
    <#
    .SYNOPSIS
    Get the current time in Unix time.
    .DESCRIPTION
    Get the time in Unix time (seconds since the Epoch)
    .PARAMETER date
    The date to convert to Unix time. If not specified, the current date and time is used.
    .EXAMPLE
    Get-UnixTime
    1706961267
    #>

    param( [Parameter(Mandatory=$false,ValueFromPipeline=$true)] [DateTime]$date = (Get-Date) )
    return [int64](New-TimeSpan -Start (Get-Date "1970-01-01T00:00:00Z").ToUniversalTime() -End ($date).ToUniversalTime()).TotalSeconds    
}
function Invoke-WebView2 {
    <#
    .SYNOPSIS
    PowerShell Interactive browser window using WebView2.
 
    .DESCRIPTION
    Uses WebView2 (a embedded edge browser) to allow embeded web technologies (HTML, CSS and JavaScript).
 
    .PARAMETER uri
    The URL to browse.
 
    .PARAMETER UrlCloseConditionRegex
    (optional, default:'error=') - The form will close when the URL matches the regex.
 
    .PARAMETER failoverToWindowsFormsWebBrowser
    (optional, defalt:true) - If WebView2 fails to initialize, the form will close and the script will continue using Windows.Forms.WebBrowser (IE11).
 
    .PARAMETER allowSingleSignOnUsingOSPrimaryAccount
    (optional, default:true) Determines whether to enable Single Sign-on with Azure Active Directory (AAD) resources inside WebView using the logged in Windows account.
 
    .PARAMETER title
    (optional, default:'PowerShell WebView') - Window-title of the form.
 
    .PARAMETER Width
    (optional, default:600) - Width of the form.
 
    .PARAMETER Height
    (optional, default:800) - Height of the form.
 
    .EXAMPLE
    PS> Invoke-WebView2 -uri "https://microsoft.com/devicelogin" -UrlCloseConditionRegex "//appverify$" -title "Device Code Flow" | Out-Null
 
    Starts a form with a WebView2 control, and navigates to https://microsoft.com/devicelogin. The form will close when the URL matches the regex '//appverify$'.
 
    #>

    param(
        [parameter(Position = 0, Mandatory = $true, HelpMessage="The URL to browse.")]
        [string]$uri,

        [parameter( Mandatory = $false, HelpMessage="Form close condition by regex (URL)")]
        [string]$UrlCloseConditionRegex = "error=[^&]*",

        [parameter( Mandatory = $false, HelpMessage="WebView2 failover to System.Windows.Forms.WebBrowser (IE11)")]
        [bool]$failoverToWindowsFormsWebBrowser = $true,

        [parameter( Mandatory = $false, HelpMessage="msSingleSignOnOSForPrimaryAccountIsShared")]
        [bool]$allowSingleSignOnUsingOSPrimaryAccount = $true,

        [parameter( Mandatory = $false, HelpMessage="Forms window title")]
        [string]$title = "PowerShell WebView",

        [parameter( Mandatory = $false, HelpMessage="Forms window width")]
        [int]$Width = "600",

        [parameter( Mandatory = $false, HelpMessage="Forms window height")]
        [int]$Height = "800"
    )
    # https://learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2environmentoptions.allowsinglesignonusingosprimaryaccount?view=webview2-dotnet-1.0.2210.55
    if ( $allowSingleSignOnUsingOSPrimaryAccount ) { $env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--enable-features=msSingleSignOnOSForPrimaryAccountIsShared" }
    else { $env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = $null }
    # initialize WebView2
    try { 
        $web = New-Object 'Microsoft.Web.WebView2.WinForms.WebView2'
        $web.CreationProperties = New-Object 'Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties'
        $web.CreationProperties.UserDataFolder = "$env:temp\PSAuthClientWebview2Cache\" 
        $web.Dock = "Fill"
        $web.source = $uri
        # close form on completion (match redirectUri) navigation
        $web.Add_SourceChanged( {
            if ( $web.source.AbsoluteUri -match $UrlCloseConditionRegex )  { $Form.close() | Out-Null }
        })
    }
    # if WebView2 fails to initialize, try to use Windows.Forms.WebBrowser
    catch {
        if ( $failoverToWindowsFormsWebBrowser ) { 
            Write-Warning "Failed to initialize WebView2, trying to use Windows.Forms.WebBrowser."
            Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
            $web = New-Object -TypeName System.Windows.Forms.WebBrowser -Property @{Width = $Width; Height = $Height; Url = $uri }
            # Close form on completion (match redirectUri) navigation
            $docCompletedEvent = {
                if ( $web.Url.AbsoluteUri -match $UrlCloseConditionRegex )  { $form.Close() }
            }
            $web.Add_DocumentCompleted($docCompletedEvent)
            $web.ScriptErrorsSuppressed = $true
            $title = $title + " [COMPATABILITY MODE]"
    }
        else { throw $_ }
    }
    # Create form
    $form = New-Object System.Windows.Forms.Form -Property @{Width=$Width;Height=$Height;Text=$title} -ErrorAction Stop
    # Add the WebBrowser control to the form
    $form.Controls.Add($web)
    $form.Add_Shown( { $form.Activate() } )
    $form.ShowDialog() | Out-Null
    $response = $web.Source
    $web.Dispose()
    return $response
}
function New-HttpListener {     
    <#
    .SYNOPSIS
    Create new HttpListener object and listen for incoming requests.
    .DESCRIPTION
    Create new HttpListener object and listen for incoming requests.
    .PARAMETER prefix
    The URI prefix handled by the HttpListener object, typically a redirect_uri.
    .EXAMPLE
    New-HttpListener -prefix "http://localhost:8080/"
    Waits for a request on http://localhost:8080/ and returns the post data.
    .EXAMPLE
    $job = Start-Job -ScriptBlock (Get-Command 'New-HttpListener').ScriptBlock -ArgumentList $redirect_uri
    Starts a Job which waits for a request on $redirect_uri.
    #>

    param(
        [parameter(Position = 0, Mandatory = $true, HelpMessage="The URI prefix handled by the HttpListener object, typically the redirect_uri.")]
        [string]$prefix
    )
    try {
        # start http listener
        $httpListener = New-Object System.Net.HttpListener
        $httpListener.Prefixes.Add($prefix)
        $httpListener.Start()
        # wait for request
        $context = $httpListener.GetContext()
        # read post request
        $form_post = [System.IO.StreamReader]::new($context.Request.InputStream).ReadToEnd()
        # send a response
        $context.Response.StatusCode = 200
        $context.Response.ContentType = 'application/json'
        $responseBytes = [System.Text.Encoding]::UTF8.GetBytes('')
        $context.Response.OutputStream.Write($responseBytes, 0, $responseBytes.Length)
        # clean up
        $context.Response.Close()
        $httpListener.Close()
    }
    catch { throw $_ } 
    return $form_post
}
function Invoke-OAuth2AuthorizationEndpoint { 
    <#
    .SYNOPSIS
    Interact with a OAuth2 Authorization endpoint.
 
    .DESCRIPTION
    Uses WebView2 (embedded browser based on Microsoft Edge) to request authorization, this ensures support for modern web pages and capabilities like SSO, Windows Hello, FIDO key login, etc
 
    OIDC and OAUTH2 Grants such as Authorization Code Flow with PKCE, Implicit Flow (including form_post) and Hybrid Flows are supported
 
    .PARAMETER uri
    Authorization endpoint URL.
 
    .PARAMETER client_id
    The identifier of the client at the authorization server.
 
    .PARAMETER redirect_uri
    The client callback URI for the authorization response.
 
    .PARAMETER response_type
    Tells the authorization server which grant to execute. Default is code.
 
    .PARAMETER scope
    One or more space-separated strings indicating which permissions the application is requesting.
 
    .PARAMETER usePkce
    Proof Key for Code Exchange (PKCE) improves security for public clients by preventing and authorization code attacks. Default is $true.
 
    .PARAMETER response_mode
    OPTIONAL Informs the Authorization Server of the mechanism to be used for returning Authorization Response. Determined by response_type, if not specified.
 
    .PARAMETER customParameters
    Hashtable with custom parameters to be added to the request uri (e.g. domain_hint, prompt, etc.) both the key and value will be url encoded.
 
    .EXAMPLE
    PS> Invoke-OAuth2AuthorizationEndpoint -uri "https://acc.spotify.com/authorize" -client_id "2svXwWbFXj" -scope "user-read-currently-playing" -redirect_uri "http://localhost"
    code_verifier xNTKRgsEy_u2Y.PQZTmUbccYd~gp7-5v4HxS7HVKSD2fE.uW_yu77HuA-_sOQ...
    redirect_uri https://localhost
    client_id 2svXwWbFXj
    code AQDTWHSP6e3Hx5cuJh_85r_3m-s5IINEcQZzjAZKdV4DP_QRqSHJzK_iNB_hN...
 
    A request for user authorization is sent to the /authorize endpoint along with a code_challenge, code_challenge_method and state param.
    If successful, the authorization server will redirect back to the redirect_uri with a code which can be exchanged for an access token.
     
    .EXAMPLE
    PS> Invoke-OAuth2AuthorizationEndpoint -uri "https://example.org/oauth2/authorize" -client_id "0325" -redirect_uri "http://localhost" -scope "user.read" -response_type "token" -usePkce:$false -customParameters @{ login = "none" }
    expires_in 4146
    expiry_datetime 01.02.2024 10:56:06
    scope User.Read profile openid email
    session_state 5c044a21-543e-4cbc-a94r-d411ddec5a87
    access_token eyJ0eXAiQiJKV1QiLCJub25jZSI6InAxYTlHksH6bktYdjhud3VwMklEOGtUM...
    token_type Bearer
 
    Implicit Grant, will return a access_token if successful.
    #>

    [Alias('Invoke-AuthorizationEndpoint','authorize')]
    [OutputType([hashtable])]
    param(
        [parameter(Position = 0, Mandatory = $true)]
        [string]$uri,

        [parameter( Mandatory = $true)]
        [string]$client_id,

        [parameter( Mandatory = $false)]
        [string]$redirect_uri,
        
        [parameter( Mandatory = $false)]
        [validatePattern("(code)?(id_token)?(token)?(none)?")]
        [string]$response_type = "code",

        [parameter( Mandatory = $false)]
        [string]$scope,

        [parameter( Mandatory = $false)]
        [bool]$usePkce = $true,

        [parameter( Mandatory = $false)]
        [ValidateSet("query","fragment","form_post")]
        [string]$response_mode,

        [parameter( Mandatory = $false)]
        [hashtable]$customParameters
    )

    # Determine which protocol is being used.
    if ( $response_type -eq "token" -or ($response_type -match "^code$" -and $scope -notmatch "openid" ) ) { 
        $protocol = "OAUTH" 
        $nonce = $null
    }
    else { $protocol = "OIDC"
        # ensure scope contains openid for oidc flows
        if ( $scope -notmatch "openid" ) { Write-Warning "Invoke-OAuth2AuthorizationRequest: Added openid scope to request (OpenID requirement)."; $scope += " openid" }
        # generate nonce for id_token validation
        [string]$nonce = Get-RandomString -Length ( (32..64) | get-random )
        Write-Verbose "Invoke-OAuth2AuthorizationRequest: adding nonce $nonce to request (OIDC flow)."
    }

    # generate state for CSRF protection (optional, but recommended)
    [string]$state = Get-RandomString -Length ( (16..21) | get-random )
    
    # begin building the request uri
    Add-Type -AssemblyName System.Web -ErrorAction Stop
    $uri += "?response_type=$([System.Web.HttpUtility]::UrlEncode($response_type))&client_id=$([System.Web.HttpUtility]::UrlEncode($client_id))&state=$([System.Web.HttpUtility]::UrlEncode($state))"
    if ( $redirect_uri ) { $uri += "&redirect_uri=$([System.Web.HttpUtility]::UrlEncode($redirect_uri))" } 
    if ( $scope ) { $uri += "&scope=$([System.Web.HttpUtility]::UrlEncode($scope))" }
    if ( $nonce ) { $uri += "&nonce=$([System.Web.HttpUtility]::UrlEncode($nonce))" }
    
    # PKCE for code flows
    if ( $response_type -notmatch "code" -and $usePkce ) { write-verbose "Invoke-OAuth2AuthorizationRequest: PKCE is not supported for implicit flows." }
    else { 
        if ( $usePkce ) {
            $pkce = New-PkceChallenge 
            $uri += "&code_challenge=$($pkce.code_challenge)&code_challenge_method=$($pkce.code_challenge_method)"
        }
    }

    # Add custom parameters to request uri
    if ( $customParameters ) { 
        foreach ( $key in $customParameters.Keys ) { 
            $urlEncodedValue = [System.Web.HttpUtility]::UrlEncode($customParameters[$key])
            $urlEncodedKey = [System.Web.HttpUtility]::UrlEncode($key)
            $uri += "&$urlEncodedKey=$urlEncodedValue"
        }
    }
    Write-Verbose "Invoke-OAuth2AuthorizationRequest $protocol request uri $uri"
    
    # if form_post: start http.sys listener as job and give it some time to start
    if ( $response_mode -eq "form_post" ) { 
        $uri += "&response_mode=form_post"
        $job = Start-Job -ScriptBlock (Get-Command 'New-HttpListener').ScriptBlock -ArgumentList $redirect_uri
        Start-Sleep -Milliseconds 500
    }

    # authorization request (interactive)
    $webSource = Invoke-WebView2 -uri $uri -UrlCloseConditionRegex "$redirect_uri|error=[^&]*|code=[^&]*" -title "Authorization code flow"
    
    # if form post - retreive job (post) after interaction has been complete
    if ( $response_mode -eq "form_post" ) {
        Write-Verbose "Invoke-OAuth2AuthorizationRequest: http.sys waiting for form_post request. (timeout 10s)"
        Wait-Job $job -Timeout 10 | Out-Null
        if ( $job.State -eq "Running" ) { 
            Write-Verbose "Invoke-OAuth2AuthorizationRequest: http.sys did not receive form_post request before timeout (10s)."; Stop-Job $job; Remove-Job $job
            throw "Invoke-OAuth2AuthorizationRequest: did not receive form_post request before timeout (10s)."
        }
        try { $jobData = Receive-Job $job -ErrorAction Stop }
        catch { 
            if ( $_.Exception.Message -match "Access is denied." ) { throw "Unabled to start http.sys listener, please run as admin." }
            else { throw "http.sys listener failed, error: $($jobData.Exception.Message)" }
        }
        finally { Remove-Job $job }
        Write-Verbose "Invoke-OAuth2AuthorizationRequest: http.sys form_post request received."
        $webSource = @{ Fragment = $jobData; Query = $null }
    }

    # When the window closes (WebView2), the script will continue and retreive the depending on the response_mode and content.
    if( $webSource.query -match "code=" ) {
        $response = @{}
        $response.code = [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['code']
        $response.state = [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['state']
        if ( $protocol -eq "oidc" ) { 
            $response.nonce = $nonce 
            if ( [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['access_token'] ) { $response.access_token = [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['access_token'] }
            if ( [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['id_token'] ) { $response.access_token = [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['id_token'] }
        }
    } 
    elseif ( $webSource.Query -match "error=" ) { 
        $errorDetails = [ordered]@{}
        $errorDetails.error = [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['error']
        $errorDetails.error_uri = [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['error_uri']
        $errorDetails.error_description = [System.Web.HttpUtility]::ParseQueryString($webSource.Query)['error_description']
        throw ($errorDetails | convertTo-Json)
    }
    elseif ( $webSource.Fragment -match "token|error=" ) {
        $response = @{}
        foreach ( $item in (($webSource.Fragment -split "#|&") | Where-Object { $_ -ne "" }) ) { 
            $key = $item.split("=")[0]
            $value = $item.split("=")[1]
            $value = [System.Web.HttpUtility]::UrlDecode($value)
            $response.$key = $value
        }
        if ( $protocol -eq "oidc" ) { $response.nonce = $nonce }
        if ( $webSource.Fragment -match "error=" ) { throw ($response | Select-Object * -ExcludeProperty state | convertTo-Json) }
    }
    else { throw "invalid response received" }

    # if code grant, add client_id, code_verifier and redirect_uri to response (needed for token exchange)
    if ( $response_type -match "code" ) {
        $response.client_id = $client_id
        if ( $usePkce ) { $response.code_verifier = $pkce.code_verifier }
        if ( $redirect_uri ) { $response.redirect_uri = $redirect_uri }
    }

    # verify state
    if ( $response.state -ne $state ) { throw "State mismatch!`nExpected '$state', got '$($response.state)'." }
    else { Write-Verbose "Invoke-OAuth2AuthorizationRequest: Validated state in response." }

    # if OIDC - validate id_token nonce
    if ( $nonce -and $response.ContainsKey("id_token") ) { 
        $decodedToken = ConvertFrom-JsonWebToken $response.id_token -Verbose:$false
        if ( $decodedToken.nonce -ne $nonce ) { throw "id_token nonce mismatch`nExpected '$nonce', got '$($decodedToken.nonce)'." }
        Write-Verbose "Invoke-OAuth2TokenExchange: Validated nonce in id_token response."
    }

    # add expiry_datetime to the response
    if ( $response.ContainsKey("expires_in") ) { 
        $response["expires_in"] = [int]$response["expires_in"]
        $response.expiry_datetime = (get-date).AddSeconds($response.expires_in) 
    }

    # remove state from response
    if ( $response.ContainsKey("state") ) { $response.Remove("state") }

    # badabing badaboom
    return $response
}
function Invoke-OAuth2DeviceAuthorizationEndpoint {
    <#
    .SYNOPSIS
    OAuth2.0 Device Authorization Endpoint Interaction
    .DESCRIPTION
    Get a unique device verification code and an end-user code from the device authorization endpoint, which then can be used to request tokens from the token endpoint.
    .PARAMETER uri
    The URI of the OAuth2.0 Device Authorization endpoint.
    .PARAMETER client_id
    The client identifier.
    .PARAMETER scope
    The scope of the access request.
    .EXAMPLE
    PS> Invoke-OAuth2DeviceAuthorizationEndpoint -uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/devicecode" -client_id $splat.client_id -scope $splat.scope
    user_code : L8EFTXRY3
    device_code : LAQABAAEAAAAmoFfGtYxvRrNriQdPKIZ-2b64dTFbGcmRF3rSBagHQGtBcyz0K_XV8ltq-nXz...
    verification_uri : https://microsoft.com/devicelogin
    expires_in : 900
    interval : 5
    message : To sign in, use a web browser to open the page https://microsoft.com/devi...
    #>

    [Alias('Invoke-DeviceAuthorizationEndpoint','deviceauth')]
    param (
        [parameter( Position = 0, Mandatory = $true, HelpMessage="Token endpoint URL.")]
        [string]$uri,
        [parameter( Mandatory = $true )]
        [string]$client_id,
        [parameter( Mandatory = $false )]
        [string]$scope
    )
    $requestBody = @{}
    $requestBody.client_id = $client_id
    if ( $scope ) { $requestBody.scope = $scope}
    $response = Invoke-RestMethod -Uri $uri -Body $requestBody
    Write-Warning -Message $response.message -ErrorAction SilentlyContinue 
    return $response
}
function Invoke-OAuth2TokenEndpoint { 
    <#
    .SYNOPSIS
    Token Exchange by OAuth2.0 Token Endpoint interaction.
 
    .DESCRIPTION
    Forge and send token exchange requests to the OAuth2.0 Token Endpoint.
 
    .PARAMETER uri
    Authorization endpoint URL.
 
    .PARAMETER client_id
    The identifier of the client at the authorisation server. (required if no other client authentication is present)
 
    .PARAMETER redirect_uri
    The client callback URI for the response. (required if it was included in the initial authorization request)
 
    .PARAMETER scope
    One or more space-separated strings indicating which permissions the application is requesting.
 
    .PARAMETER code
    Authorization code received from the authorization server.
 
    .PARAMETER code_verifier
    Code_verifier, required if code_challenge was used in the authorization request (PKCE).
 
    .PARAMETER device_code
    Device verification code received from the authorization server.
 
    .PARAMETER client_secret
    client credential as string or securestring
 
    .PARAMETER client_auth_method
    OPTIONAL client (secret) authentication method. (default: client_secret_post)
 
    .PARAMETER client_certificate
    client credential as x509certificate2, RSA Private key or cert location 'Cert:\CurrentUser\My\THUMBPRINT'
 
    .PARAMETER nonce
    OPTIONAL nonce value used to associate a Client session with an ID Token, and to mitigate replay attacks.
 
    .PARAMETER refresh_token
    Refresh token received from the authorization server.
 
    .PARAMETER customHeaders
    Hashtable with custom headers to be added to the request uri (e.g. User-Agent, Origin, Referer, etc.).
 
    .EXAMPLE
    PS> $code = Invoke-OAuth2AuthorizationEndpoint -uri $authorization_endpoint @splat
    PS> Invoke-OAuth2TokenEndpoint -uri $token_endpoint @code
    token_type : Bearer
    scope : User.Read profile openid email
    expires_in : 4948
    ext_expires_in : 4948
    access_token : eyJ0eXAiOiJKV1QiLCJujHu5ZSI6IllQUWFERGtkVEczUjJIYm5tWFlhOG1QbHk1ZTlwckJybm...
    refresh_token : 0.AUcAjvFfm8BTokWLwpwMkH65xiGBP5hz2ZpErJuc3chlhOUNAVw.AgABAAEAAAAmoFfGtYxv...
    id_token : eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtXYmthYTZxczh3c1RuQndpaU5ZT2...
    expiry_datetime : 01.02.2024 12:51:33
 
    Invokes the Token Endpoint by passing parameters from the authorization endpoint response such as code, code_verifier, nonce, etc.
 
    .EXAMPLE
    PS> Invoke-OAuth2TokenEndpoint -uri $token_endpoint -scope ".default" -client_id "123" -client_secret "secret"
    token_type : Bearer
    expires_in : 3599
    ext_expires_in : 3599
    access_token : eyJ0eXAiOiJKV1QikjY6b25jZSI6IkZ4YTZ4QmloQklGZjFPT0FqQZQ4LTl5WUEtQnpqdXNzTn...
    expiry_datetime : 01.02.2024 12:33:01
 
    Client authentication using client_secret_post
 
    .EXAMPLE
    PS> Invoke-OAuth2TokenEndpoint -uri $token_endpoint -scope ".default" -client_id "123" "Cert:\CurrentUser\My\8kge399dddc5521e04e34ac19fe8f8759ba021b8"
    token_type : Bearer
    expires_in : 3599
    ext_expires_in : 3599
    access_token : eyJ0eXYiOiJKV1QiLCJusk6jZSI6IlNkYU9lOTdtY0NqS0g1VnRURjhTY3JSMEgwQ0hje24fR1...
    expiry_datetime : 01.02.2024 12:36:17
 
    Client authentication using private_key_jwt
 
    #>

    [Alias('Invoke-TokenEndpoint','token')]
    [cmdletbinding(DefaultParameterSetName='code')]
    param(
        [parameter( Position = 0, Mandatory = $true, ParameterSetName='client_certificate')]    
        [parameter( Position = 0, Mandatory = $true, ParameterSetName='client_secret')]
        [parameter( Position = 0, Mandatory = $true, ParameterSetName='code')]
        [parameter( Position = 0, Mandatory = $true, ParameterSetName='device_code')]
        [parameter( Position = 0, Mandatory = $true, ParameterSetName='refresh')]
        [string]$uri,

        [parameter( Mandatory = $false, ParameterSetName='client_certificate')]
        [parameter( Mandatory = $false, ParameterSetName='client_secret')]
        [parameter( Mandatory = $false, ParameterSetName='code')]
        [parameter( Mandatory = $false, ParameterSetName='device_code')]
        [parameter( Mandatory = $false, ParameterSetName='refresh')]
        [string]$client_id,

        [parameter( Mandatory = $false, ParameterSetName='client_certificate')]
        [parameter( Mandatory = $false, ParameterSetName='client_secret')]
        [parameter( Mandatory = $false, ParameterSetName='code')]
        [parameter( Mandatory = $false, ParameterSetName='refresh')]
        [string]$redirect_uri,

        [parameter(Mandatory = $false, ParameterSetName='client_certificate')]
        [parameter(Mandatory = $false, ParameterSetName='client_secret')]
        [parameter(Mandatory = $false, ParameterSetName='code')]
        [parameter(Mandatory = $false, ParameterSetName='refresh')]
        [string]$scope,

        [parameter( Mandatory = $false, ParameterSetName='client_certificate')]
        [parameter( Mandatory = $false, ParameterSetName='client_secret')]
        [parameter( Mandatory = $false, ParameterSetName='code')]
        [string]$code,

        [parameter( Mandatory = $false, ParameterSetName='client_certificate')]
        [parameter( Mandatory = $false, ParameterSetName='client_secret')]
        [parameter( Mandatory = $false, ParameterSetName='code')]
        [string]$code_verifier,

        [parameter( Mandatory = $true, ParameterSetName='device_code')]
        [string]$device_code,

        [parameter( Mandatory = $true, ParameterSetName='client_secret')]
        [parameter( Mandatory = $false, ParameterSetName='code')]
        [parameter( Mandatory = $false, ParameterSetName='refresh')]
        $client_secret,

        [parameter( Mandatory = $false, ParameterSetName='client_secret', HelpMessage="client_credential type")]
        [parameter( Mandatory = $false, ParameterSetName='refresh')]
        [ValidateSet('client_secret_basic','client_secret_post','client_secret_jwt')]
        $client_auth_method = "client_secret_post",

        [parameter( Mandatory = $true, ParameterSetName='client_certificate', HelpMessage="private_key_jwt")]
        [parameter( Mandatory = $false, ParameterSetName='refresh', HelpMessage="private_key_jwt")]
        $client_certificate,

        [parameter( Mandatory = $false, ParameterSetName='client_certificate')]
        [parameter( Mandatory = $false, ParameterSetName='client_secret')]
        [parameter( Mandatory = $false, ParameterSetName='code')]
        [parameter( Mandatory = $false, ParameterSetName='refresh')]
        [string]$nonce,

        [parameter( Mandatory = $true, ParameterSetName='refresh')]
        $refresh_token,

        [parameter( Mandatory = $false, ParameterSetName='client_certificate')]
        [parameter( Mandatory = $false, ParameterSetName='client_secret')]
        [parameter( Mandatory = $false, ParameterSetName='code')]
        [parameter( Mandatory = $false, ParameterSetName='device_code')]
        [parameter( Mandatory = $false, ParameterSetName='refresh')]
        [hashtable]$customHeaders
    )

    $payload = @{}
    $payload.headers = @{ 'Content-Type' = 'application/x-www-form-urlencoded' }
    $payload.method  = 'Post'
    $payload.uri     =  $uri

    # Custom headers provided
    if ( $customHeaders ) { 
        if ( $customHeaders.Keys -contains "Content-Type" ) { $payload.headers = $customHeaders }
        else { $payload.headers += $customHeaders }
    }
    
    # Build request body and determine grant_type
    $requestBody = @{}
    if ( $client_id ) { $requestBody.client_id = $client_id }
    if ( $redirect_uri ) { $requestBody.redirect_uri = $redirect_uri }
    if ( $scope ) { $requestBody.scope = $scope }
    if ( ( $client_secret -or $client_certificate) -and !$code -and !$refresh_token ) { 
        $requestBody.grant_type = "client_credentials" 
    }
    elseif ( $code ) { 
        $requestBody.grant_type = "authorization_code"
        $requestBody.code = $code 
        if ( $code_verifier ) { $requestBody.code_verifier = $code_verifier }
    }
    elseif ( $device_code ) { 
        $requestBody.grant_type = "urn:ietf:params:oauth:grant-type:device_code"
        $requestBody.device_code = $device_code
    }
    elseif ( $refresh_token ) { 
        $requestBody.grant_type = "refresh_token"
        $requestBody.refresh_token = $refresh_token
    }

    # client authentication
    if ( $client_secret ) { 
        if ( $client_secret.GetType().Name -eq "SecureString" ) { 
            # Psv5 ConvertFrom-SecureString does not have -AsPlainText Param
            if ( $PSVersionTable.PSEdition -eq "Core" ) { $client_secret = $client_secret | ConvertFrom-SecureString -AsPlainText }
            else {
                # secureString.toBinaryString.toStringUnit
                $client_secret = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
                    [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($client_secret)
                )
            }
        }
        switch ( $client_auth_method ) {
            "client_secret_basic" { $payload.headers.Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($client_id):$($client_secret)")) }
            "client_secret_post" { $requestBody.client_secret = $client_secret }
            "client_secret_jwt" { $requestBody.client_assertion = (New-Oauth2JwtAssertion -aud $uri -iss $client_id -sub $client_id -client_secret $client_secret -ErrorAction Stop).client_assertion_jwt }
        }
    }
    elseif ( $client_certificate) {
        if ( $client_certificate.GetType().Name -notmatch "^X509Certificate|^RSA" ) {
            try { $client_certificate = Get-Item $client_certificate -ErrorAction Stop }
            catch { throw $_ }
        }
        $requestBody.client_assertion = (New-Oauth2JwtAssertion -aud $uri -iss $client_id -sub $client_id -client_certificate $client_certificate -ErrorAction Stop).client_assertion_jwt
    }
    if ( $client_certificate -or $client_auth_method -eq "client_secret_jwt" ) { $requestBody.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}

    $payload.body = $requestBody
    write-verbose ($payload | ConvertTo-Json -Compress)
    try { $response = Invoke-RestMethod @payload -Verbose:$false }
    catch { throw $_ }

    # validate id_token nonce
    if ( $nonce -and $response.id_token ) { 
        $decodedToken = ConvertFrom-JsonWebToken $response.id_token -Verbose:$false
        if ( $decodedToken.nonce -ne $nonce ) { throw "id_token nonce mismatch`nExpected '$nonce', got '$($decodedToken.nonce)'." }
        Write-Verbose "Invoke-OAuth2TokenExchange: Validated nonce in id_token"
    }

    # add expiry datetime
    if ( $response.expires_in ) { $response | Add-Member -NotePropertyName expiry_datetime -TypeName NoteProperty (get-date).AddSeconds($response.expires_in) }
    
    # badabing badaboom
    return $response
}
function New-Oauth2JwtAssertion {
    <#
    .SYNOPSIS
    Create a JWT Assertion for OAuth2.0 Client Authentication.
     
    .DESCRIPTION
    Create a JWT Assertion for OAuth2.0 Client Authentication.
 
    .PARAMETER issuer
    iss, must contain the client_id of the OAuth Client.
 
    .PARAMETER subject
    sub, must contain the client_id of the OAuth Client.
 
    .PARAMETER audience
    aud, should be the URL of the Authorization Server's Token Endpoint.
 
    .PARAMETER jwtId
    jti, unique token identifier. Random GUID by default.
 
    .PARAMETER customClaims
    Hashtable with custom claims to be added to the JWT payload (assertion).
 
    .PARAMETER client_certificate
    Location Cert:\CurrentUser\My\THUMBPRINT, x509certificate2 or RSA Private key.
 
    .PARAMETER key_id
    kid, key identifier for assertion header
 
    .PARAMETER client_secret
    clientsecret for HMAC signature
 
    .EXAMPLE
    PS> New-Oauth2JwtAssertion -issuer $client_id -subject $client_id -audience $oidcDiscoveryMetadata.token_endpoint -client_certificate $cert
 
    client_assertion_jwt ew0KICAidHlwIjogIkpXVCIsDQogICJhbGciOiAiUlMyNTYiDQp9.ew0KICAia...
    client_assertion_type urn:ietf:params:oauth:client-assertion-type:jwt-bearer
    header @{typ=JWT; alg=RS256}
    payload @{iss=PSAuthClient; nbf=1706785754; iat=1706785754; sub=PSAu...}
 
    #>

    [cmdletbinding(DefaultParameterSetName='private_key_jwt')]
    param(
        [parameter( Position = 0, Mandatory = $true, ParameterSetName='private_key_jwt', HelpMessage="iss, must contain the client_id of the OAuth Client.")]
        [parameter( Position = 0, Mandatory = $true, ParameterSetName='client_secret_jwt', HelpMessage="iss, must contain the client_id of the OAuth Client.")]
        [string]$issuer,

        [parameter( Position = 1, Mandatory = $true, ParameterSetName='private_key_jwt', HelpMessage="sub, must contain the client_id of the OAuth Client.")]
        [parameter( Position = 1, Mandatory = $true, ParameterSetName='client_secret_jwt', HelpMessage="sub, must contain the client_id of the OAuth Client.")]
        [string]$subject,

        [parameter( Position = 2, Mandatory = $true, ParameterSetName='private_key_jwt', HelpMessage="aud, should be the URL of the Authorization Server's Token Endpoint.")]
        [parameter( Position = 2, Mandatory = $true, ParameterSetName='client_secret_jwt', HelpMessage="aud, should be the URL of the Authorization Server's Token Endpoint.")]
        [string]$audience,

        [parameter( Position = 3, Mandatory = $false, ParameterSetName='private_key_jwt', HelpMessage="jti, unique token identifier.")]
        [parameter( Position = 3, Mandatory = $false, ParameterSetName='client_secret_jwt', HelpMessage="jti, unique token identifier.")]
        [string]$jwtId = [string]([guid]::NewGuid()),

        [parameter( Mandatory = $false, ParameterSetName='private_key_jwt', HelpMessage="Hashtable with custom claims.")]
        [parameter( Mandatory = $false, ParameterSetName='client_secret_jwt', HelpMessage="Hashtable with custom claims.")]
        [hashtable]$customClaims,

        [parameter( Mandatory = $true, ParameterSetName='private_key_jwt', HelpMessage="Location Cert:\CurrentUser\My\THUMBPRINT, x509certificate2 or RSA Private key.")]
        $client_certificate,

        [parameter( Mandatory = $false, ParameterSetName='private_key_jwt', HelpMessage="key identifier for assertion header")]
        $key_id,

        [parameter( Mandatory = $true, ParameterSetName='client_secret_jwt', HelpMessage="ClientSecret")]
        $client_secret
    )
    $jwtHeader = @{ alg = "RS256"; typ = "JWT" }
    # certificate properties
    if ( $client_certificate ) {
        if ( $client_certificate.GetType().Name -notmatch "^X509Certificate|^RSA" ) {
            try { $client_certificate = Get-Item $client_certificate -ErrorAction Stop }
            catch { throw $_ }
        }
        if ( $client_certificate.GetType().Name -match "^X509Certificate" ) { $jwtHeader.x5t = ConvertTo-Base64urlencoding $client_certificate.GetCertHash() }
        if ( $key_id ) { $jwtHeader.kid = $key_id }
    }
    elseif ( $client_secret ) {
        if ( $client_secret.GetType().Name -eq "SecureString" ) { 
            # Psv5 ConvertFrom-SecureString does not have -AsPlainText Param
            if ( $PSVersionTable.PSEdition -eq "Core" ) { $client_secret = $client_secret | ConvertFrom-SecureString -AsPlainText }
            else {
                # secureString.toBinaryString.toStringUnit
                $client_secret = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
                    [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($client_secret)
                )
            }
        }
    }
    $jwtHeader = $jwtHeader | ConvertTo-Json
    # build assertion payload
    $jwtClaims = @{
        aud = $audience                 # URL of the resource using the JWT to authenticate to
        exp = (Get-UnixTime) + 300      # expiration time of the token
        jti = $jwtId                    # (optional) unique identifier for the token
        iat = Get-UnixTime              # (optional) time the token was issued
        iss = $issuer                   # issuer of token (client_id)
        sub = $subject                  # subject of the token (client_id)
        nbf = Get-UnixTime              # (optional) time before which the token is not valid
    } 
    if ( $customClaims ) { foreach ( $key in $customClaims.Keys ) { $jwtClaims.$key = $customClaims[$key] } }
    $jwtClaims = $jwtClaims | ConvertTo-Json
    # unsigned assertion in base64url encoding
    $jwtAssertion = (ConvertTo-Base64urlencoding $jwtHeader) + "." + (ConvertTo-Base64urlencoding $jwtClaims)
    # assertion signing - cert or secret
    if ( $client_certificate ) {
        if ( $client_certificate.GetType().Name -match "^X509" ) { $signature = convertTo-Base64urlencoding $client_certificate.PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($jwtAssertion),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) }
        elseif ( $client_certificate.GetType().Name -match "^RSA" ) { $signature = convertTo-Base64urlencoding $client_certificate.SignData([System.Text.Encoding]::UTF8.GetBytes($jwtAssertion),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) }
    }
    else { 
        $hmacsha = New-Object System.Security.Cryptography.HMACSHA256
        $hmacsha.key = [Text.Encoding]::UTF8.GetBytes($client_secret)
        $signature = $hmacsha.ComputeHash([Text.Encoding]::UTF8.GetBytes($jwtAssertion))
        $signature = convertTo-Base64urlencoding ( [Convert]::ToBase64String($signature) )
    }
    # Finalize response
    $response = [ordered]@{}
    $response.client_assertion_jwt = $jwtAssertion + "." + $Signature
    $response.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
    $response.header = $jwtHeader | ConvertFrom-Json
    $response.payload = $jwtClaims  | ConvertFrom-Json
    return $response
}
function New-PkceChallenge { 
    <#
    .SYNOPSIS
    Generate code_verifier and code_challenge for PKCE (authorization code flow).
    .DESCRIPTION
    Generate code_verifier and code_challenge for PKCE (authorization code flow).
    .EXAMPLE
    PS> New-PkceChallenge
    code_verifier Vpq2YXOsD~1DRM-jBPR6bt8R-3dWQAHNLVLUIDxh7SkWpOT3A0grpenqKne5rAHcVKsTi-ya8-lGBxJ0NS7zavdcFbfdN0yFQ5kYOFbWBh3
    code_challenge TW-3r-6mxRWjhkkxmYOabLlwIQ0JkQ0ndxzOSLJvCoU
    code_challenge_method S256
    #>

    # Generate code_verifier and code_challenge for PKCE (authorization code flow).
    $response = [ordered]@{}
    # code_verifier (should be a random string using the characters "[A-Z,a-z,0-9],-._~" between 43 and 128 characters long)
    [string]$response.code_verifier = Get-RandomString
    # dereive code_challenge from code_verifier (SHA256 hash) and encode using base64urlencoding (fallback to plain)
    try { 
        $hashAlgorithm = [System.Security.Cryptography.HashAlgorithm]::Create('sha256') 
        $hashInBytes = $hashAlgorithm.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($response.code_verifier))
        [string]$response.code_challenge = ConvertTo-Base64urlencoding $hashInBytes
        [string]$response.code_challenge_method = "S256"
    }
    catch {
        [string]$response.code_challenge = $response.code_verifier
        [string]$response.code_challenge_method = "plain"
    }  
    return $response
}
function Test-JsonWebTokenSignature {
    <#
    .SYNOPSIS
    Test the signature of a JSON Web Token (JWT)
 
    .DESCRIPTION
    Automatically attempt to test the signature of a JSON Web Token (JWT) by using the issuer discovery metadata to get the signing certificate if no signing certificate or secret was provided.
 
    .PARAMETER jwtInput
    The JSON Web Token (string) to be must be in the form of a valid JWT.
 
    .PARAMETER SigningCertificate
    X509Certificate2 object to be used for RSA signature verification.
 
    .PARAMETER client_secret
    Client secret to be used for HMAC signature verification.
 
    .EXAMPLE
    PS> Test-JsonWebTokenSignature -jwtInput $jwt
 
    Decodes the JWT and attempts to verify the signature using the issuer discovery metadata to get the signing certificate if no signing certificate or secret was provided.
 
    .EXAMPLE
    PS> Test-JsonWebTokenSignature -jwtInput $jwt -SigningCertificate $cert
 
    Decodes the JWT and attempts to verify the signature using the provided certificate.
 
    .EXAMPLE
    PS> Test-JsonWebTokenSignature -jwtInput $jwt -client_secret $secret
 
    Decodes the JWT and attempts to verify the signature using the provided client secret.
 
    #>

    [OutputType([bool])]
    [cmdletbinding(DefaultParameterSetName='certificate')]
    param ( 
        [Parameter( Position = 0, Mandatory=$true, ValueFromPipeline=$true, ParameterSetName='certificate')]
        [Parameter( Position = 0, Mandatory=$true, ValueFromPipeline=$true, ParameterSetName='client_secret')]
        [ValidatePattern("^e[yJ|w0]([a-zA-Z0-9_-]+[.]){2}", Options = "None")]
        $jwtInput,

        [Parameter(Mandatory = $false, ParameterSetName='certificate')]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$SigningCertificate,

        [Parameter(Mandatory = $true, ParameterSetName='client_secret')]
        $client_secret
    )

    try { $decodedJwt = ConvertFrom-JsonWebToken $jwtInput }
    catch { throw $_ }
    
    # attempt to get signing key from discovery metadata if not specified
    if ( !$signingCertificate -and !$client_secret ) { 
        try {
            Write-Verbose "Test-JWTSignature: Attempting to get signing key from issuer discovery metadata"
            $signingKey = (Invoke-RestMethod -uri (get-oidcDiscoveryMetadata $decodedJwt.iss).jwks_uri -Verbose:$false).keys | Where-Object kid -eq $decodedJwt.header.kid
            if ( !$signingKey ) { throw "Test-JWTSignature: Unable to get signing key from issuer discovery metadata, please specify certificate in input parameter." }
            $signingCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( [convert]::FromBase64String( $signingKey.x5c ) )
            Write-Verbose "Test-JWTSignature: Retrieved keyId $($signingKey.kid) from issuer $($decodedJwt.iss)"
        } 
        catch { throw $_ }
    }

    # data to be verified
    $data = [System.Text.Encoding]::UTF8.GetBytes( ($jwtInput -split "[.]")[0..1] -join "." ) # (YES, DOT-included ,_. ffs.)

    if ( $SigningCertificate ) { 
        # public key
        if ( $decodedJwt.header.alg -match "^[RS|PS]" ) { 
            $publicKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($signingCertificate) 
            if ( $decodedJwt.header.alg -match "^PS" ) { $padding = [Security.Cryptography.RSASignaturePadding]::Pss }
            else { $padding = [Security.Cryptography.RSASignaturePadding]::Pkcs1 }
        }
        elseif ( $decodedJwt.header.alg -match "^ES" ) { $publicKey = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPublicKey($signingCertificate) }
        else { throw "Test-JWTSignature: Unsupported algorithm $($decodedJwt.header.alg)" } # https://www.iana.org/assignments/jose/jose.xhtml#IESG

        # signature
        [byte[]]$sig = ConvertFrom-Base64UrlEncoding $decodedJwt.signature -rawBytes

        # alg
        $alg = "SHA$(($decodedJwt.header.alg -replace '[a-z]'))"
        Write-Verbose "Test-JWTSignature: attempting to verify $($decodedJwt.header.alg) signature"
        if ( $padding ) { [bool]$response = $publicKey.VerifyData( $data, $sig, $alg, [Security.Cryptography.RSASignaturePadding]::Pkcs1) }
        else { [bool]$response = $publicKey.VerifyData( $data, $sig, $alg ) }
    }
    elseif ( $client_secret ) {
        Write-Verbose "Test-JWTSignature: attempting to verify HMAC signature"
        $hmacsha = New-Object System.Security.Cryptography.HMACSHA256
        $hmacsha.key = [Text.Encoding]::UTF8.GetBytes($client_secret)
        $signature = $hmacsha.ComputeHash($data)
        $signature = convertTo-Base64urlencoding ( [Convert]::ToBase64String($signature) )
        if ( $signature -eq $decodedJwt.signature ) { $response = $true }
        else { $response = $false }
    }
    return $response
}