Introduction

In the past, I was always curious about the workings of Connect-AzAccount, the authentication command from the Az.Accounts PowerShell module. This led me to delve into debugging, and the subsequent article is a product of that exploration. It’s intriguing that both Az CLI and Az PowerShell are operational across all tenants, even the newly created ones. I aimed to emulate this functionality in PowerShell and utilize it in my scripts. For instance, this could be beneficial when executing commands across various tenants, a task that the Az modules are not adept at handling.

Authentication flows

Before beginning the debugging and disassembly of the commands, it’s essential to discuss the authentication flows, particularly those utilized by Connect-AzAccount. Much of this information is available on Microsoft Learn, for example over here or here, but it’s all very convoluted in my opinion.This article will concentrate on the Authorization Code Flow. Subsequent articles will cover Client Credentials - Secret, Client Credentials - Certificate flows, Federated Credential, and Managed Identity.

Components

Initially, let’s briefly examine the components that facilitate this functionality. There are two main components: an enterprise application, also known as a service principal, which may exist across multiple tenants or within a single tenant. Additionally, there is an app registration, also known as an application that is confined to a single “home” tenant.

The app registration:

  • Branding for interactive login
  • Defines the client secret / certificate / federated credential
  • Defines the API permissions the application requires
  • Defines the token claims (properties) and app roles
  • Exists in the home tenant with unique object id which will almost never need

The enterprise app:

  • Defines the permissions (can be both Entra, Azure or other) for this tenant
  • Defines the allowed users / groups
  • SCIM provisioning (user provisioning in the application)
  • Exists in each tenant which implements the application with unique object id in each tenant which you require for example for Azure RBAC role assignments

These items are linked by the application id / app id / client id depending on which portal you are located in.

Powershell Enterprise App

Now we reach the core of the story: the authentication cmdlets function across all tenants because the app registration resides with Microsoft, and a service principal is automatically created for each tenant upon a user’s first login via the corresponding PowerShell module. This is only possible because this is a first party application built by Microsoft, a list can be found over here.

The client ID for the Azure PowerShell module is “1950a258-227b-4e31-a9cf-717495945fc2,” which remains consistent for all users. However, the enterprise application possesses a distinct object ID within each tenant.

Flow Details

Authentication schema

As you can see in the above picture, which I shamelessly stole from the OAuth docs, the process exists in 2 big steps:

  • A call to the /authorize endpoint to retrieve an authorization code + use hashed
  • A call to the /token endpoint to retrieve an access token and optionally refresh token depending on the scope requested

Mind you this is an interactive authentication flow requiring a user to authenticate and authorize!

Code Explanation

I have crafted a script that illustrates the operation of the Authorization Code Flow using exclusively Powershell 7, with a slight incorporation of .NET. The script is divided into three functions, each detailed below:

  • AuthorizationCodeRetrieval: Initiates the default browser with the authorization endpoint and scopes, with an option to logout immediately afterward. It also displays chat-GPT generated JavaScript and HTML. Additionally, it creates the PKCE to safeguard against MITM attacks. The code verifier is produced and hashed using SHA256. This hashed value, the code challenge, is used when requesting the authorization code. In the subsequent step, the code verifier’s unhashed value is needed to exchange the authorization code for an access token or refresh token. Upon calling the authorization endpoint, an asynchronous HTTP listener on port 8400 must be opened (for the Azure Powershell App Registration managed by Microsoft). Once the listener is active, it waits for user authentication and the return of the authorization code to our script, which is then forwarded to the next function.

  • AccessTokenRetrieval: Converts the previously obtained authorization code into an access token, which is then used to access our resources. It also requires the code verifier’s unhashed value from the prior function to finalize the PKCE process. This function yields the access token and, if applicable, the refresh token (which necessitates the offline_access scope).

  • PowershellInteractiveLogin: This function acts as the connector for the aforementioned functions, establishing default parameters and orchestrating the call of both functions while managing the transfer of inputs and outputs.

Code

Some troubles I came across when trying to create this were:

  • Documentation on the Authorization Code flow is abundant, yet it appears that MSAL is the preferred choice for many (which is not a negative aspect, but understanding it necessitates additional information).
  • Getting the details on the PKCE requirements was a pain to find out (you might start to notice a pattern).
  • I have almost no knowledge of Javascript / HTML / …, but Chat GPT / Gemini / … do!
  • Noticing that I should use an async listener, otherwise the user is not able to stop the process when you don’t get correctly authenticated. (except when killing the Powershell process).
  1function AuthorizationCodeRetrieval {
  2  param (
  3    [Parameter(Mandatory = $true)][String]$clientId,
  4    [Parameter(Mandatory = $true)][String]$scope,
  5    [Parameter(Mandatory = $true)][String]$tenantId,
  6    [Parameter(Mandatory = $true)][String]$redirectUri,
  7    [Parameter(Mandatory = $false)][Bool]$logoutAfterAuth
  8  )
  9  ### Static vars ###
 10  $authUrl = ("https://login.microsoftonline.com/$($tenantId)/oauth2/v2.0/authorize" )
 11
 12  ### Optional redirect towards logout page ###
 13  if ($logoutAfterAuth) {
 14    $htmlScript = @"
 15      <script>
 16        // Function to redirect to another page
 17        function redirect() {
 18          window.location.href = "https://login.microsoftonline.com/common/oauth2/logout"; 
 19        }
 20        
 21        // Countdown timer function
 22        function startCountdown() {
 23          var countdown = 10; // Countdown in seconds
 24        
 25          var countdownInterval = setInterval(function() {
 26            countdown--;
 27            
 28            // Display the countdown
 29            document.getElementById("countdown").textContent = countdown;
 30            
 31            // If countdown reaches 0, redirect to another page
 32            if (countdown <= 0) {
 33              clearInterval(countdownInterval);
 34              redirect();
 35            }
 36          }, 1000); // Update every second
 37        }
 38        
 39        // Start the countdown when the page loads
 40        window.onload = function() {
 41          startCountdown();
 42        };
 43      </script>
 44"@
 45
 46    $htmlText = @'
 47      <p>You will be logged out in <span id="countdown">10</span> seconds.</p>
 48'@
 49  }
 50  else {
 51    $htmlScript = ""
 52    $htmlText = @'
 53      <p>You can close this window now.</p>
 54'@
 55  }
 56
 57  ### Page to be returned after logon ###
 58  $html = @"
 59    <!DOCTYPE html>
 60    <html>
 61    <head>
 62      <meta charset="UTF-8">
 63      <meta name="viewport" content="width=device-width, initial-scale=1.0">
 64      <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
 65      <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
 66      <title>Login Successful</title>
 67      <style>
 68        body {
 69          display: flex;
 70          justify-content: center;
 71          align-items: center;
 72          height: 100vh;
 73          background-color: #f5f5f5;
 74        }
 75        
 76        .card {
 77          padding: 24px;
 78          width: 400px;
 79          text-align: center;
 80        }
 81        
 82        .material-icons {
 83          vertical-align: middle;
 84        }
 85      </style>
 86      $htmlScript
 87    </head>
 88    <body>
 89      <div class="card">
 90        <h3>You are now logged in!</h3>
 91        $htmlText
 92        <i class="material-icons">done</i>
 93      </div>
 94      <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
 95    </body>
 96    </html>
 97"@
 98
 99  ### Generate a random string to use as the code verifier to prevent man in the middle attacks ###
100  $code_verifier = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).Replace('+', '-').Replace('/', '_').Replace('=', '')
101  $code_verifier_bytes = [System.Text.Encoding]::UTF8.GetBytes($code_verifier)
102  $code_verifier_hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($code_verifier_bytes)
103  $code_challenge = [Convert]::ToBase64String($code_verifier_hash).Replace('+', '-').Replace('/', '_').Replace('=', '')
104
105  $state = -join ([char[]]'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' | Get-Random -Count 32)
106
107  ### Generate hashtable with querystring values ###
108  $params = @{
109    response_type         = 'code'
110    client_id             = $clientId
111    redirect_uri          = $redirectUri
112    scope                 = $scope
113    prompt                = 'select_account'
114    state                 = $state
115    code_challenge        = $code_challenge
116    code_challenge_method = 'S256'
117  }
118
119  ### Convert the hashtable to a querystring using HttpUtility ###
120  $queryString = [System.Web.HttpUtility]::ParseQueryString("")
121  foreach ($param in $params.GetEnumerator()) {
122    $queryString.Add([System.Web.HttpUtility]::UrlEncode($param.Key), $param.Value)
123  }
124  $queryStringStr = $queryString.ToString()
125
126  ### Construct the complete URI with the querystring ###
127  $uri = New-Object System.UriBuilder($authUrl)
128  $uri.Query = $queryStringStr
129
130  ### Start listener on localhost ###
131  $httpListener = New-Object System.Net.HttpListener
132  $httpListener.Prefixes.Add($redirectUri + '/')
133  $httpListener.Start()
134
135  ### Start default browser with authorization url ###
136  Start-Process $uri
137
138  try {
139
140    ### Async wait for the authorization token to be returned ###
141    $contextTask = $httpListener.GetContextAsync()
142    while (-not $contextTask.AsyncWaitHandle.WaitOne(200)) { }
143    $context = $contextTask.GetAwaiter().GetResult()
144
145    ### Return the html to show that the user is logged in ###
146    $response = $context.Response
147    $buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
148    $response.ContentType = "text/html"
149    $response.ContentEncoding = [System.Text.Encoding]::UTF8
150    $response.ContentLength64 = $buffer.Length
151    $response.OutputStream.Write($buffer, 0, $buffer.Length)
152    $response.OutputStream.Close()
153
154    ### Extract the authorizaton code from the response ###
155    $authCode = [System.Web.HttpUtility]::ParseQueryString($context.Request.Url.Query)['code']
156
157  }
158  catch {
159    Write-Warning ("Authorization code retrieval failed: $($Error[0].Exception.Message)" )
160  }
161  finally {
162    $httpListener.Stop()
163  }
164
165  return @{
166    authcode      = $authCode
167    code_verifier = $code_verifier
168  } 
169}
170
171function AccessTokenRetrieval {
172  param (
173    [Parameter(Mandatory = $true)][String]$clientId,
174    [Parameter(Mandatory = $true)][String]$tenantId,
175    [Parameter(Mandatory = $true)][String]$redirectUri,
176    [Parameter(Mandatory = $true)][String]$authCode,
177    [Parameter(Mandatory = $true)][String]$code_verifier
178  )
179  ### Static vars ###
180  $tokenUrl = ("https://login.microsoftonline.com/$($tenantId)/oauth2/v2.0/token")
181
182  ### Generate hashtable with querystring values ###
183  $body = @{
184    grant_type    = 'authorization_code'
185    client_id     = $clientId
186    redirect_uri  = $redirectUri
187    code          = $authCode
188    code_verifier = $code_verifier
189  }
190
191  ### Call token endpoint to exchange authorization token for access token ###
192  $response = Invoke-WebRequest -Method Post -Uri $tokenUrl -Body $body
193  if ($response.StatusCode -eq "200") {
194    $content = ($response.Content | ConvertFrom-Json)
195    return @{
196      access_token  = $content.access_token
197      refresh_token = $content.refresh_token
198      id_token      = $content.id_token
199    }
200  }
201  else {
202    Write-Warning "Access Code retrieval failed: $($response.StatusCode)"
203  }
204}
205
206function PowershellAuthorizationCodeLogin {
207  param (
208    [Parameter(Mandatory = $false)][String]$clientId = '1950a258-227b-4e31-a9cf-717495945fc2',
209    [Parameter(Mandatory = $false)][String]$scope = 'https://management.azure.com/.default openid profile email offline_access',
210    [Parameter(Mandatory = $false)][String]$tenantId = 'common',
211    [Parameter(Mandatory = $false)][Bool]$logoutAfterAuth = $false
212  )
213    
214  ### Variables ###
215  $redirectUri = 'http://localhost:8400'
216  $commonParams = @{
217    clientId    = $clientId
218    tenantId    = $tenantId
219    redirectUri = $redirectUri
220  }
221
222  ### Call authorization code function ###
223  $AuthorizationCodeFlowParams = $commonParams.Clone()
224  $AuthorizationCodeFlowParams.Add("logoutAfterAuth", $logoutAfterAuth)
225  $AuthorizationCodeFlowParams.Add("scope", $scope)
226  $authParams = AuthorizationCodeRetrieval @AuthorizationCodeFlowParams
227
228  ### Call access token function ###
229  $accessTokenRetrievalParams = $commonParams.Clone()
230  $accessTokenRetrievalParams.Add("authCode", $authParams.authCode)
231  $accessTokenRetrievalParams.Add("code_verifier", $authParams.code_verifier)
232  $accessToken = AccessTokenRetrieval @accessTokenRetrievalParams
233
234  ### Return access and refresh token if offline_access has been added to scopes ###
235  return $accessToken
236}
237
238### Call authentication function ###
239$tokens = PowershellAuthorizationCodeLogin

Conclusion

I’m thrilled with the script I’ve created, as it has enabled me to gain a deeper understanding of the commands “Connect-AzAccount”, “Connect-MgGraph”, and what MSAL fundamentally does behind the scenes. I hope others trying to decipher this process won’t have to endure the same challenges I faced! May you enjoy applying this knowledge in your own scripts, and be sure to let me know if you have any questions surrounding this topic!