graph.auth.lite.psm1

function Get-GraphToken {
    <#
        .SYNOPSIS
        Get security tokens (access and/or refresh tokens) for Microsoft Graph.
 
        .DESCRIPTION
        Obtain Microsoft Identity Platform security tokens via OAuth 2.0 client credentials grant or authorization code grant flow (supports multi-factor auth).
 
        .PARAMETER tenantId
        Tenant identifier or a verified domain belonging to the tenant.
 
        .PARAMETER clientId
        Client/application Identifier.
 
        .PARAMETER Scopes
        (Optional) String of space-separated scopes for the resource, include 'offline_access' if you want to aquire a refresh_token.
 
        .PARAMETER redirectUri
        (Optional) Address to return to upon receiving a response from the authority.
 
        .PARAMETER integratedWindowsAuth
        (Optional) Non-interactive request to acquire a security token for the signed-in user in Windows, via Integrated Windows Authentication.
 
        .PARAMETER RefreshToken
        (Optional) Provide refresh_token to obtain a new access_token (include offline_access scope to also renew the refresh_token).
 
        .PARAMETER Secret
        Shared Secret - for client credentials flow
 
        .PARAMETER Certificate
        location eg. 'Cert:\CurrentUser\My\THUMBPRINT' for Clients Credential Flow - Certificate
 
        .EXAMPLE
        Interactive Code flow that will prompt a user to sign in and return access_token.
        C:\PS>Get-GraphToken -tenantId $tenantId -clientId $clientId
 
        .EXAMPLE
        Interactive Code flow that will return access_token and refresh_token.
        C:\PS>Get-GraphToken -tenantId $cspTenant -clientId $clientId -scopes "openid offline_Access"
 
        .EXAMPLE
        Use refresh_token grant to retreive access_token and a renewed refresh_token.
        C:\PS>Get-GraphToken -tenantId $custTenant -clientId $clientId -refreshtoken $rt
    #>

    [cmdletbinding(DefaultParameterSetName='code')]
    param(
        [parameter(Position = 0, Mandatory = $true, ParameterSetName='code', HelpMessage="Domain or GUID")]
        [parameter(Position = 0, Mandatory = $true, ParameterSetName='secret', HelpMessage="Domain or GUID")]
        [parameter(Position = 0, Mandatory = $true, ParameterSetName='certificate', HelpMessage="Domain or GUID")]
        [string]$tenantId,

        [parameter(Position = 1, Mandatory = $false, ParameterSetName='code', HelpMessage="AppID")]
        [parameter(Position = 1, Mandatory = $false, ParameterSetName='secret', HelpMessage="AppID")]
        [parameter(Position = 1, Mandatory = $false, ParameterSetName='certificate', HelpMessage="AppID")]
        [string]$clientId = '1950a258-227b-4e31-a9cf-717495945fc2', # default = Microsoft Azure PowerShell ClientID

        [parameter(Mandatory = $false, ParameterSetName='code')]
        [parameter(Mandatory = $false, ParameterSetName='secret')]
        [parameter(Mandatory = $false, ParameterSetName='certificate')]
        [string]$scopes = 'https://graph.microsoft.com/.default',

        [parameter(Mandatory = $false, ParameterSetName='code')]
        [parameter(Mandatory = $false, ParameterSetName='secret')]
        [parameter(Mandatory = $false, ParameterSetName='certificate')]
        [string]$redirectUri = 'https://login.microsoftonline.com/common/oauth2/nativeclient',

        [parameter(Mandatory = $false, ParameterSetName='code')]
        [switch]$integratedWindowsAuth,

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

        [parameter(Mandatory = $true, ParameterSetName='secret', HelpMessage="Shared secret")]
        [String]$Secret,

        [parameter(Mandatory = $true, ParameterSetName='certificate', HelpMessage="Location Cert:\CurrentUser\My\THUMBPRINT")]
        $Certificate
    )
    begin {
        # Define requestBody
        $requestBody = @{}
        $requestBody.client_id = $clientId
        $requestBody.scope = $scopes

        # Define payload
        $payload = @{}
        $payload.uri     = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
        $payload.method  = 'Post'
    }
    process {
        if ( $secret ) {
            write-debug "client_credentials flow - secret: $($ClientId) $($tenantId)"
            $requestBody.grant_type = 'client_credentials'
            $requestBody.client_secret = $Secret
        }
        elseif ( $Certificate ) {
            # https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow
            write-debug "client credentials flow - certificate: $($ClientId) $($tenantId)"
            try { $Certificate = Get-Item $Certificate -ErrorAction Stop }
            catch { throw $_ }

            # Assertion header
            $jwtHeader = @{
                alg = "RS256"
                typ = "JWT"
                x5t = ConvertTo-Base64urlencoding $certificate.GetCertHash() # x.509 cert SHA-1 thumbprint
            } | ConvertTo-Json

            # time on or after which the jwt must not be accepted for processing
            $expUnixtime = [math]::Round((New-TimeSpan -Start (Get-Date "1970-01-01T00:00:00Z" ).ToUniversalTime() -End (Get-Date).ToUniversalTime().AddMinutes(5)).TotalSeconds,0)

            # Assertion payload
            $jwtClaims = @{
                aud = $payload.uri
                exp = $expUnixtime
                iss = $ClientId
                jti = [guid]::NewGuid() # unique identifier
                nbf = (New-TimeSpan -Start (Get-Date "1970-01-01T00:00:00Z" ).ToUniversalTime() -End ((Get-Date).ToUniversalTime())).TotalSeconds
                sub = $ClientId
            } | ConvertTo-Json

            # unsigned assertion (base64url encoded header and payload)
            $jwtAssertion = (ConvertTo-Base64urlencoding $jwtHeader) + "." + (ConvertTo-Base64urlencoding $jwtClaims)

            # System.Security.Cryptography.RSACryptoServiceProvider.SignData - assertion signature in accordance with RFC.
            $signature = convertTo-Base64urlencoding $Certificate.PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($jwtAssertion),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)

            # Finalize jwt and params.
            $requestBody.client_assertion = $jwtAssertion + "." + $Signature
            $requestBody.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
            $requestBody.grant_type = "client_credentials"
        }
        else {
            Write-Debug "code authorization flow: $($ClientId) $($tenantId)"

            # refresh_token grant
            if ( $refreshToken ) {
                $payload.headers = @{ 'Content-Type' = 'application/x-www-form-urlencoded' }
                $requestBody.grant_type    = 'refresh_token'
                $requestBody.refresh_token = $refreshToken
                $requestBody.redirect_uri = $redirectUri
            }

            # Code authorization flow (interactive)
            else {
                #https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
                Write-Debug "interactive flow"

                # Build codeflow payload
                $codeflowPayload = @{}
                # used to avoid cross site request forgery (not required, but recommended)
                $codeflowPayload.state = [guid]::NewGuid()
                # PKCE - code_challenge secret (minimum length 43 char, maximum length 128)
                $codeflowPayload.Verifier = [guid]::NewGuid().guid + [guid]::NewGuid().guid
                # Load System.Security.Cryptograaphy.SHA256
                $hashAlgorithm = [System.Security.Cryptography.HashAlgorithm]::Create('sha256')
                # Compute hash from secret
                $hashInBytes = $hashAlgorithm.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($codeflowPayload.Verifier))
                # Convert hash to base64url encoding
                $codeflowPayload.CodeChallenge = ConvertTo-Base64urlencoding $hashInBytes

                # Indicates the type of user interaction that is required. Valid values are login, none, consent, and select_account.
                if ( $integratedWindowsAuth ) { $prompt = "none" }
                else { $prompt = "login" }

                # URL Encoding
                Add-Type -AssemblyName System.Web
                $redirectUriEncoded =  [System.Web.HttpUtility]::UrlEncode($redirectUri)
                $scopeEncoded = [System.Web.HttpUtility]::UrlEncode($scopes)
                $url = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?response_type=code&client_id=$ClientID&redirect_uri=$redirectUriEncoded&scope=$scopeEncoded&prompt=$prompt&state=$($codeflowPayload.state)&code_challenge=$($codeflowPayload.CodeChallenge)&code_challenge_method=S256"

                <# Auth dialog code (Windows.Forms/Web Interaction) has been lifted from @darrenjrobinson
                https://gist.github.com/darrenjrobinson/b74211f98c507c4acb3cdd81ce205b4f #>

                [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

                Add-Type -AssemblyName System.Windows.Forms
                $form = New-Object -TypeName System.Windows.Forms.Form -Property @{Width = 440; Height = 640 }
                $web = New-Object -TypeName System.Windows.Forms.WebBrowser -Property @{Width = 420; Height = 600; Url = $url }

                # Close form on completion (nice!)
                $docCompletedEvent = {
                    $Global:uri = $web.Url.AbsoluteUri
                    if ($Global:uri -match "error=[^&]*|code=[^&]*") { $form.Close() }
                }
                $web.Add_DocumentCompleted($docCompletedEvent)

                $web.ScriptErrorsSuppressed = $true
                $form.Controls.Add($web)
                $form.Add_Shown( { $form.Activate() })
                $form.ShowDialog() | Out-Null
                # End of user interaction.

                $codeResponse = @{}
                $codeResponse.code = [System.Web.HttpUtility]::ParseQueryString($web.Url.Query)['code']
                $codeResponse.session_state = [System.Web.HttpUtility]::ParseQueryString($web.Url.Query)['session_state']
                $codeResponse.state = [System.Web.HttpUtility]::ParseQueryString($web.Url.Query)['state']
                if ( $codeResponse.state -ne $codeflowPayload.state ) { throw "state mismatch!"}

                $payload.headers = @{ 'Content-Type' = 'application/x-www-form-urlencoded' }
                $requestBody.redirect_uri  = $redirectUri
                $requestBody.grant_type    = "authorization_code"
                $requestBody.code_verifier = $codeflowPayload.Verifier
                $requestBody.code          = $CodeResponse.Code
            }
        }
        # Finalization of payload & delivery
        $payload.body = $requestBody
        $response = Invoke-RestMethod @payload
    }
    end {
        # Append expiry_datetime to response
        if ( $response.expires_in ) { $expDateTime = get-date -Format o (get-date).AddSeconds($response.expires_in) }
        if ( $response.expireson.DateTime ) { $expDateTime = get-date -format o $response.expireson.DateTime }
        $response | Add-Member -NotePropertyName expiry_datetime -TypeName NoteProperty $expDateTime
        return $response
    }
}
function Clear-GraphTokenCache {
    <#
        .SYNOPSIS
        Clear windows forms webbrowser session data. (https://itecnote.com/tecnote/c-how-to-clear-system-windows-forms-webbrowser-session-data/)
        .DESCRIPTION
        Clear windows forms webbrowser session data. (https://itecnote.com/tecnote/c-how-to-clear-system-windows-forms-webbrowser-session-data/)
    #>

    $memberDefinition = '[DllImport("wininet.dll", SetLastError = true, CharSet=CharSet.Auto)] public static extern bool InternetSetOption(IntPtr hInternet, int dwOption, IntPtr lpBuffer, int dwBufferLength);'
    $type = Add-Type -MemberDefinition $memberDefinition -Name wininet -Namespace pinvoke -PassThru
    # INTERNET_OPTION_END_BROWSER_SESSION:https://learn.microsoft.com/en-us/windows/win32/wininet/option-flags
    $type::InternetSetOption(0, 42, 0, 0)
}
function ConvertTo-Base64urlencoding {
    param ($value)
    if ( $value.GetType().Name -eq "String" ) { $value = [System.Text.Encoding]::UTF8.GetBytes($value) }
    return ( [System.Convert]::ToBase64String($value) -replace '\+','-' -replace '/','_' -replace '=' )
}