Table of Contents

In my work on the practical implementation of Password Rotation without Privileged Authentication Administrator, I stumbled upon a somewhat extensive challenge.

When trying to use PowerShell to sign in to the Graph API using username + password, I couldn’t find a combination in the PowerShell SDK.
The only method would be ClientID + Secret – but that would mean using App Permissions again, which I specifically want to avoid in this case.

We also know that it is possible, because in the Azure command line such a connection is available – but to demand the Az module or the Azure CLI just for this contradicts my perfectionism. Especially since it’s also not trivial to authenticate against a specific App Registration this way.

Accordingly, the lazy solution already exists – the token can then be further used with Connect-MgGraph -AccessToken $(ConvertTo-SecureString "<AccessToken>")

Since such a specific sub-problem soon took up half the article, I prefer to summarize separately how I arrived at my solution.


Initial Investigation

Starting with the knowledge that there is a working implementation, I decided to get more details. This led me to the Resource Owner Password Credentials (ROPC) grant. In the associated documentation, we find very detailed information on how to use it.

Ideally, not at all:

ROPC Warning

Modern authentication protocols are designed, so that user login is separated from the application as far as possible – in all other OIDC flows, we simply get confirmation that the user is authenticated. Here, we temporarily hold his credentials – where we mostly wouldn’t need to.

However, the key phrase is: "in most scenarios". If I want to log in without interaction as a normal user, to my knowledge, there is no alternative – but I am open to correction. The upside in my scenario is worth it, because paradoxically, in my very specific use case, user permissions are miles more granular than application rights – and I already have the user passwords in the PAM vault anyways.

However, we will also very likely not find any SDKs that work the way I would like: I agree with Microsoft, I do not want this app to be accessed by users. Otherwise, they could authenticate without MFA and make requests against the API. The best way would be to register a confidential client app, where requests always have to include a certificate to prove that the system is allowed to use the app registration. But:

ROPC No Confidential

Understandable, assuming that at login passwords would be passed on to the server. Not applicable in our case, however, because we already have the passwords.

If the SDKs don’t support it, we will have to replicate the Web Requests.


Building the Code

I may often put in more effort than necessary (Like right now? 🤔) – but I don’t want to be ridiculous. There are more than enough implementations of Graph API Auth via Invoke-WebRequest – so I will take the best possible version and adapt it for ROPC.

The best version to me seemed to be the one by Adam The Automator – especially since it also already implements certificate-based authentication, which I probably would have struggled with for a while.

But in my first tests of the code, I stumble over a problem:

Error Connecting

After a bit of troubleshooting, I identify the following passage:

#...
$Certificate = Get-Item Cert:\CurrentUser\My\$thumbprint
#...

# Get the private key object of your certificate
$PrivateKey = $Certificate.PrivateKey

# Define RSA signature and hashing algorithm
$RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
$HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256

# Create a signature of the JWT
$Signature = [Convert]::ToBase64String(
    $PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT),$HashAlgorithm,$RSAPadding)
) -replace '\+','-' -replace '/','_' -replace '='
#...

Probably because my private keys are non-exportable, $PrivateKey remained empty.
However, private keys that cannot be exported are still usable on the system where they are installed – otherwise, Connect-MgGraph -CertificateThumbprint -ClientId would also fail.

If we construct a .Net object directly from the certificate that can provide us with a signature, we can make progress:

$rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
$Signature = [Convert]::ToBase64String(
    $rsaCert.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding)
) -replace '\+', '-' -replace '/', '_' -replace '='

SuccessfulSignature


After that, adjustments take a while but are less interesting to present: I

  1. transfer the HTTP examples from the documentation into corresponding PowerShell syntax
  2. use the Access Token to connect with Connect-MgGraph
  3. parameterize everything so that it becomes a reusable function
  4. incorporate authentication against Public Clients and with Client Secret, for the sake of completion
  5. ensure that the sensitive variables are cleared


Usage

Requirements:

  • As usual, you need an Enterprise application with the delegated rights that you want to use later
  • If no App Credentials are to be used, the registration must be marked as a "Public Client App" in the Advanced Settings
    • Please avoid this if possible
  • The app must be exempted from MFA and Compliant Device controls in Conditional Access
    • Ideally, at least the IP address should be checked, and access should be restricted to necessary users


Connecting is very easy:

$params = @{
  #alternativ get-credentials
  userCredentials       = $creds
  tenantId              = $config.tenantID
  clientId              = $config.clientID
  scopes                = @("User.Read","Directory.AccessAsUser.All")
  certificateThumbprint = $config.thumb
}
Connect-ROPCGraph @params

The structure is deliberately as close as possible to Connect-MgGraph – but we still need the Users credentials.

At first, you don’t get any output – but that’s intentional, as the function is built for non-interactive processes. We see the success when we execute other commands from the Graph SDK and find that everything works as usual:

Successfull Connection

Restrictions
TL:DR of the big warning Block of ROPC; The Following won’t work

  • Microsoft Personal Accounts
  • Passwordless Accounts
  • Logins, that need MFA
  • Federated Accounts
  • Passwords with Spaces at the Beginning or End


Summary

Now we can log in as an end user without user intervention and take advantage of the associated benefits. WHowever, we are aware that there is a reason why this flow is not made easily available – explicit use of user passwords and bypassing MFA should be used as rarely as possible.

And I can wrap up my Passwordrotation-PoC 😉


I will not moderate comments and I do not want your email address; please participate in the discussion through my associated LinkedIn Post.


If you are interested in the things I do follow me on LinkedIn.


Translation assisted by ChatGPT

Full Script

Most Current Version

function Connect-ROPCGraph {
    param (
        [Parameter(ParameterSetName = "PublicClient", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]$userCredentials,

        [Parameter(ParameterSetName = "PublicClient", Mandatory = $true)]
        [switch]$publicClient,

        [Parameter(ParameterSetName = "PublicClient", Mandatory = $false)]
        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [string]$tenantId,

        [Parameter(ParameterSetName = "PublicClient", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [string]$clientId,

        [Parameter(ParameterSetName = "PublicClient", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [array]$scopes,

        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [ValidateNotNull()]
        [string]$certificateThumbprint,

        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [ValidateNotNull()]
        [securestring]$clientSecret
    )

    # Depending on which Type of Client Credentials were used we generate the Request Body
    switch ($PSCmdlet.ParameterSetName) {
        "PublicClient" {
            $Body = @{
                client_id  = $clientId
                scope      = [string]$scopes
                username   = $userCredentials.UserName
                password   = $userCredentials.GetNetworkCredential().Password
                grant_type = "password"
            }
        }
        "ClientCert" {
            # If we are using Certificate Credentials we have to generate a JWT Assertion
            # Based on https://adamtheautomator.com/powershell-graph-api/ - the original certificate usage did not work for me though
            try {
                # Load Certificate
                $Certificate = Get-Item "Cert:\CurrentUser\My\$certificateThumbprint" -ErrorAction Stop

                # Get base64 hash of certificate in Web Encoding
                $CertificateBase64Hash = [System.Convert]::ToBase64String($Certificate.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '='
            }
            catch {
                throw "Error Reading Certificate"
            }

            $StartDate = (Get-Date "1970-01-01T00:00:00Z").ToUniversalTime()

            # Create JWT timestamp for expiration
            $JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds
            $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0)

            # Create JWT validity start timestamp
            $NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds
            $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0)

            # Create JWT header
            $JWTHeader = @{
                alg = "RS256"
                typ = "JWT"
                x5t = $CertificateBase64Hash 
            }

            # Create JWT payload
            $JWTPayLoad = @{
                aud = "https://login.microsoftonline.com/$tenantID/oauth2/token"
                exp = $JWTExpiration
                iss = $clientID
                jti = [guid]::NewGuid()
                nbf = $NotBefore
                sub = $clientID
            }

            # Convert header and payload to base64
            $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json))
            $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte)

            $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json))
            $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte)

            $JWT = $EncodedHeader + "." + $EncodedPayload

            # Define RSA signature and hashing algorithm
            $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
            $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256

            # Sign the JWT
            $rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
            $Signature = [Convert]::ToBase64String(
                $rsaCert.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding)
            ) -replace '\+', '-' -replace '/', '_' -replace '='

            # Add Signature to JWT
            $JWT = $JWT + "." + $Signature

            $Body = @{
                client_id             = $clientId
                client_assertion      = $JWT
                client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                scope                 = [string]$scopes
                username              = $userCredentials.UserName
                password              = $userCredentials.GetNetworkCredential().Password
                grant_type            = "password"
            }
        }
        "ClientCredentials" {
            $Body = @{
                client_id     = $clientId
                client_secret = [System.Net.NetworkCredential]::new("", $clientSecret).Password
                scope         = [string]$scopes
                username      = $userCredentials.UserName
                password      = $userCredentials.GetNetworkCredential().Password
                grant_type    = "password"
            }
        }
    }

    $params = @{
        Uri         = "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token"
        Method      = 'POST'
        ContentType = 'application/x-www-form-urlencoded'
        Body        = $Body
        # If we use a JWT we must add an Authorization Header
        Headers     = if ($JWT) { @{ Authorization = "Bearer $JWT" } }
    }
    $accessToken = ConvertTo-SecureString (Invoke-RestMethod @params -ErrorAction Stop).access_token -AsPlainText -Force

    # Use our Access token to Connect to Microsoft Graph
    Connect-MgGraph -AccessToken $accessToken -NoWelcome

    # Clear Senstive Values
    $sensitiveVars = @("userCredentials","accessToken","body","params","jwt","signature")
    Remove-Variable $sensitiveVars
    [gc]::collect()
}
Last modified: 11. February 2024