MSEndpointMgr

Understanding OAuth: Coding the authentication flow yourself vs using an SDK

Dive into Microsoft Graph authentication with PowerShell. In this blog we explore OAuth flows, PKCE security, and token handling. Learn how to build a secure auth flow from scratch and why the SDK might still be the best choice for automation.

I haven’t been shy about blogging about the Microsoft Graph SDK recently. If you’ve ever used the SDK, you’ve probably taken for granted how it magically handles authentication for you. Modules like MSAL.PS (deprecated now cry) and its successor Microsoft.Graph.Authentication are like a good cheese – they just work, and get better with age and refinement.

I have always been fascinated by token issuance and renewal and just took it for granted. The authentication modules just abstracted that complexity away from me. This is actually a good thing! Do I have enough skill and discipline to handle tokens safely? Maybe not…so that spawned this journey.

In this deep dive, we’ll build an OAuth authentication flow from scratch in PowerShell. We’ll see exactly what happens during the authentication flow, understand the security challenges, and learn why certain protective measures like PKCE are crucial. By the end, hopefully I’ll have helped you understand both how to implement your own OAuth solution and why actually bother….the SDK might still be your best choice for your automation projects.

Setting Up Your Application

Before we dive into code, let’s understand what we need and why. To authenticate to Microsoft services, you’ll need to register an application in Entra ID.

App Registration

In Microsoft Entra ID, an app registration is necessary when you want an application to authenticate and interact with Microsoft services such as Microsoft Graph.

What Does an App Registration Provide?

One thing I always used to ask myself is “Why?” Why do I need an app registration? Can I not just authenticate to the thing in the cloud without the added overhead of creating one?

A logical follow on question, before we answer the previous one, is can I just use the existing enterprise applications for Graph Explorer and the Microsoft Graph SDK? They work just fine right?

If you didn’t already know, when you sign into Graph Explorer, it authenticates using an enterprise app managed by Microsoft called Graph Explorer. Similarly, when using the Microsoft Graph SDK, authentication is handled through another Microsoft-managed enterprise application, called Microsoft Graph Command Line Tools, ensuring an out-of-the-box seamless experience without requiring manual app registration.

Existing Enterprise Apps

When designing your own authentication flows, it is best to create your own app registration in Entra ID. This gives you, as the application owner, full control over things like optional claims, API permissions, stricter control with conditional access policies and ensures that the client app can only perform actions within the scope of your project’s needs. By managing your own app, you define exactly what your application can and cannot do.

Authentication Flows – Application vs Delegated

In my previous blog https://msendpointmgr.com/2025/01/12/deep-diving-microsoft-graph-sdk-authentication-methods/ we covered some of this in detail.

Basically, in the delegated flow, the client application acts on behalf of the signed-in user, meaning the level of access to your application (or the Microsoft Graph) is determined by the combination of the permissions granted to the user and the permissions defined by the scopes you request during authentication. This means that even if you request broad scopes, such as User.Read.All, you are still limited by what the user’s role and permissions in Entra ID allow. In other words, the client app (app registration) can only perform actions that the signed-in user is authorized to do.

In the application flow, the client app authenticates as itself rather than impersonating a user. This means the app’s access to your application (or the Microsoft Graph) is controlled entirely by the application permissions assigned to it in Entra ID and not influenced by a user’s role or permissions. In this flow, the app does not request specific granular scopes like in the delegated flow. Instead, it uses a default scope that tells Microsoft Graph to apply the app’s pre-assigned permissions.

Can you spot which API permissions have been granted for the application and which have been delegated to the application by the user?

For the code examples in this blog, we’ll be mostly using a delegated authentication flow, meaning the client application (app registration) will perform actions on behalf of an authenticated user. The user must sign in, and the app will only be able to access resources within the permissions granted to that user.

Setting Up Your Redirect URI

When you register your application in Entra ID, you’ll notice some redirect URIs are pre-configured. However, for both our custom PowerShell script and if you’re using the Microsoft Graph SDK, you’ll need to add your own localhost redirect URI.

The Redirect URI is super important. An authorization code, a temporary credential issued by the authorization server after a user/principal successfully authenticates, is sent to the redirect URI, acting as a one-time token that your application can exchange for an access token. An authorization code is short-lived (10 minutes) and single-use only, with specific security restrictions, like the code can only be redeemed by the same application (client ID) that requested it.

Adding Your Redirect URI

  1. Go to Azure Portal > Entra ID > App Registrations
  2. Select your application
  3. Under ‘Authentication’, scroll to ‘Platform configurations’
  4. Choose ‘Add a platform’ if you haven’t already
  5. Select ‘Mobile and desktop applications’
  6. Add ‘http://localhost‘ in the suggested redirect URIs

HTTP Listeners

For our custom PowerShell implementation, this localhost URI is crucial because our script creates a temporary web server (HTTP Listener) to receive the authorization code. Similarly, the Microsoft Graph SDK also uses localhost for its authentication flow and listens on localhost for a response.

We could create a simple http listener on a specific port like this:-

$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 8080)
$listener.Start()
Write-Host "Listening on http://localhost:8080/"
Write-Host "Open your browser and navigate to http://localhost:8080/"

$client = $listener.AcceptTcpClient()
Write-Host "Connection received!"

# Send a simple HTTP response
$html = @"
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 100

<html><body><h1>So this worked, cool. We did something cool.</h1></body></html>
"@

$stream = $client.GetStream()
$buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
$stream.Write($buffer, 0, $buffer.Length)
$stream.Flush()

# Close connection and stop listener
$client.Close()
$listener.Stop()
Write-Host "Server stopped."

This example code will create the listener and wait for a connection, before finally closing the listener.

The problem with using static ports, like the example above, is the port may already be in use. We need to find a port in the dynamic range (49152 – 65535) that isn’t being used.

function Get-AvailablePort {

    # Try getting a dynamic port first
    $tcpListener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0)
    try {
        $tcpListener.Start()
        $port = ([System.Net.IPEndPoint]$tcpListener.LocalEndpoint).Port
        
        # If we got a port outside dynamic range, try again with explicit range
        if ($port -lt 49152) {
            $tcpListener.Stop()
            
            # Try ports in dynamic range
            for ($port = 49152; $port -le 65535; $port++) {
                $tcpListener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $port)
                try {
                    $tcpListener.Start()
                    return $port
                }
                catch {
                    $tcpListener.Stop()
                    continue
                }
            }
            throw "No available ports found in dynamic range"
        }
        return $port
    }
    finally {
        $tcpListener.Stop()
    }
}

This approach is good because we use a random port in the dynamic range to avoid conflict and the temporary web server only exists during the auth flow. Keep your room tidy!

Protecting the Authorization Code

We briefly discussed that the localhost is where Entra ID is going to send the authorization code that we will use to obtain a token. Unless you protect the token, it could be intercepted and used maliciously (albeit within 10 minutes and using the same app registration). Here is what an unprotected response would look like, the authorization code is in the header in plain text.

http://localhost:57653/?
code=1.AYEA9PHrDMTg1EaMWg_IC-1rLOo1ftCGJQZBrXa4uimSgZZEAfSBAA.AgABBAIAAABVrSpeuWamRam2jAF1XRQEAwDs_wUA9P9FhhenZn8FmZM_CO_KSJXpkWqY_uikoVln1-V7vMEyMmExoQqQqniCnYZCPA7reYCm9L8dOiz1zhQ8ybuHRk5OmZEVSFY__GNHdNglKkI18Mei2Q09wyMZHClpVcWkmoh78BR1H9lBK1eGZIKflK-piJKLIQCUzJb_1hoRPkm0L_JHCkZcMGT2ncOvc4q85eik-zP8bpY1vEtlS3q8vUmSdmaTOjxZYpl0JETB4kDC9EsBPvI7l5YMKHlidvspWvguMuHsFt00C_YLfAMGp6KIQi3niTQukgQUNy4ntit4aCoIglM8B1epmjQeeBIHOyKktr2oOdUMGjYQtWqRkzk4WNd5MWSSDlXxyv1Hbf2WWhaws6tJCCqlKi8Ope_GDdx9JlWYTzM0LxN7fpkOgHQx5f4bm6nzaYsUFNfZ3ejX01Be1AfDPWw4V6nIPxw-YNptpgVYxWjNWBq6kTx1JAovzNEq1d7uKa1XW5YKOrF50_z_d1xzZwtdWpybqGGtjpnmMzHQvVW9QoJb9wCM9Ykk7af6uE-7o-4noCVXUDKB-PZOEf8iP5C2bG9Ui-iLwLG_uyZiYlMRAkaqAkuKaws3SLQepzOkFyukWwjg-v8CEuC6hxcvmdFLZ4LhhUmcR7x27Ck61CU53Wm1avsx-IXiJZAcBIoFcY7lXy2t9y27iVwr-Xh_1L0G48DqjCpZHHx8gn1j2zwxhbjQcdTBe2gYWm4MNZW_SRosjO6-YjVu0pT39y3sHh5v3GN--ejQaS7s9aKMuzKltdXPxuZ90EthBhUtpaKQfM2Bdf3-_aeXUTZZX84sr-pTwfux2lNWdD2jnFlmrsjVAEK-Er7ZC-holm1ynF1ytGjX3VirEi4quNJpH2GRUCRE9ZwJW870VAoL6mQ
session_state=001febf9-9e69-f198-9bc4-71b5686a6b2d

The SessionState is unimportant, its what the identity platform is using to track this authentication request. The code is the important part!

Here’s how an attacker could intercept and misuse your authorization code. First lets assume its a normal day in Smallville and perform an authentication and grab the authorization code.

$tenantId = ''
$clientId = ''

# Get a dynamic available port using the function we created earlier
$port = Get-AvailablePort
$redirectUri = "http://localhost:$port"

# Construct the auth URL
$authUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize" `
    + "?client_id=$clientId" `
    + "&response_type=code" `
    + "&redirect_uri=$redirectUri" `
    + "&scope=User.Read"

# Open in Edge on macOS - just because
if ($IsMacOS) {
    & "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" $authUrl
} else {
    Start-Process $authUrl
}

Can I use this code from another device? Let’s try

# Authorization code from the browser
$code = "1.AYEA9PHrDMTg1EaMWg_IC-1rLOo1ftCGJQZBrXa4uimSgZZEAfSBAA.AgABBAIAAABVrSpeuWamRam2jAF1XRQEAwDs_wUA9P9FhhenZn8FmZM_CO_KSJXpkWqY_uikoVln1-V7vMEyMmExoQqQqniCnYZCPA7reYCm9L8dOiz1zhQ8ybuHRk5OmZEVSFY__GNHdNglKkI18Mei2Q09wyMZHClpVcWkmoh78BR1H9lBK1eGZIKflK-piJKLIQCUzJb_1hoRPkm0L_JHCkZcMGT2ncOvc4q85eik-zP8bpY1vEtlS3q8vUmSdmaTOjxZYpl0JETB4kDC9EsBPvI7l5YMKHlidvspWvguMuHsFt00C_YLfAMGp6KIQi3niTQukgQUNy4ntit4aCoIglM8B1epmjQeeBIHOyKktr2oOdUMGjYQtWqRkzk4WNd5MWSSDlXxyv1Hbf2WWhaws6tJCCqlKi8Ope_GDdx9JlWYTzM0LxN7fpkOgHQx5f4bm6nzaYsUFNfZ3ejX01Be1AfDPWw4V6nIPxw-YNptpgVYxWjNWBq6kTx1JAovzNEq1d7uKa1XW5YKOrF50_z_d1xzZwtdWpybqGGtjpnmMzHQvVW9QoJb9wCM9Ykk7af6uE-7o-4noCVXUDKB-PZOEf8iP5C2bG9Ui-iLwLG_uyZiYlMRAkaqAkuKaws3SLQepzOkFyukWwjg-v8CEuC6hxcvmdFLZ4LhhUmcR7x27Ck61CU53Wm1avsx-IXiJZAcBIoFcY7lXy2t9y27iVwr-Xh_1L0G48DqjCpZHHx8gn1j2zwxhbjQcdTBe2gYWm4MNZW_SRosjO6-YjVu0pT39y3sHh5v3GN--ejQaS7s9aKMuzKltdXPxuZ90EthBhUtpaKQfM2Bdf3-_aeXUTZZX84sr-pTwfux2lNWdD2jnFlmrsjVAEK-Er7ZC-holm1ynF1ytGjX3VirEi4quNJpH2GRUCRE9ZwJW870VAoL6mQ"

# Construct the token URL
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
$body = @{
    client_id = $clientId
    code = $code
    redirect_uri = $redirectUri
    grant_type = "authorization_code"
    scope = "User.Read"
}

# Make the token request
$token = Invoke-RestMethod -Uri $tokenUrl -Method Post -Body $body

# Show the token response
$token | ConvertTo-Json

We have a token! Just to demonstrate, for completeness, we cannot use the same authorization code again to request a token.

Whats the big deal Ben? An attacker could trick users into using a malicious app that registers a similar-looking redirect URI (like localh0st instead of localhost) or perform a man-in-the-middle attack on a network to intercept the authentication response. This is known as an “Authorization Code Interception Attack” (ACIA) and is specifically addressed in RFC 7636. Basically, the authorization code is about as secure as a thermal exhaust port on the Death Star.

When the auth code appears in the URL, the attacker could race to submit their token request first. Since an auth code can only be exchanged once for a token, if the attacker’s request succeeds first, your legitimate app’s request would fail.

Let me show you how to protect against this with Proof Key for Code Exchange (PKCE). PKCE prevents this type of attack by requiring the attacker to also know a secret code verifier value that was generated by your legitimate app at the start of the auth flow.

Implementing PKCE Protection

PKCE prevents this type of attack by requiring proof that the application exchanging the code is the same one that initiated the flow. It works by creating a one-way relationship between two values – a random code verifier and its SHA256 hashed equivalent called a code challenge. The code verifier remains private with the legitimate app while the code challenge is sent with the initial authorization request. Since SHA256 is a one-way hash, an attacker who intercepts the code challenge cannot reverse it to get the code verifier. PKCE primarily protects against attacks where the auth code is intercepted at a different point than the token exchange (like malicious redirects or network sniffing between different machines).

The PKCE flow summarised:-

Here’s how we could implement it with a simple function:-

function Generate-PKCE {

    # Generate a random code verifier
    $codeVerifier = -join ((65..90) + (97..122) | Get-Random -Count 128 | % {[char]$_})

    # Create a SHA256 hash of the verifier
    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($codeVerifier)

    # Base64URL encode the hash to create the challenge
    $codeChallenge = [System.Convert]::ToBase64String($sha256.ComputeHash($bytes)) `
        -replace '\+','-' -replace '/','_' -replace '='

    return @{ 
        Verifier = $codeVerifier  # Keep this secret until token exchange
        Challenge = $codeChallenge # Send this with initial auth request
    }
}

Now when we request the token, we can prove we are the original requester by providing the code verifier in our request. This code example uses Write-Host to show the PKCE values in use.

$tenantId = ''
$clientId = ''
    
# Get a dynamic available port using the function we previously created
$port = Get-AvailablePort
$redirectUri = "http://localhost:$port"
Write-Host ("Using redirect URI: {0}" -f $redirectUri) -ForegroundColor Cyan

# Set up HTTP listener before launching browser
$http = [System.Net.HttpListener]::new()
$http.Prefixes.Add("$redirectUri/")
$http.Start()

# Generate PKCE values for protection using the function we just created
$PKCE = Generate-PKCE
Write-Host "`nPKCE Values:" -ForegroundColor Yellow
Write-Host ("Code Verifier: {0}" -f $PKCE.Verifier) -ForegroundColor Yellow
Write-Host ("Code Challenge: {0}" -f $PKCE.Challenge) -ForegroundColor Yellow

# Construct auth URL with PKCE challenge
$authUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize" `
    + "?client_id=$clientId" `
    + "&response_type=code" `
    + "&redirect_uri=$redirectUri" `
    + "&scope=User.Read" `
    + "&code_challenge=$($PKCE.Challenge)" `
    + "&code_challenge_method=S256"

Write-Host "`nLaunching browser with auth URL..." -ForegroundColor Green

# Launch browser
if ($IsMacOS) {
    & "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" $authUrl
} else {
    Start-Process $authUrl
}

# Wait for and get the auth code from the response
Write-Host "Waiting for auth code..." -ForegroundColor Cyan
$context = $http.GetContext()
$code = $context.Request.QueryString["code"]
$http.Stop()

Write-Host "`nReceived auth code:" -ForegroundColor Green
Write-Host ("{0}" -f $code) -ForegroundColor Green

Write-Host "`nExchanging code for token using PKCE verifier..." -ForegroundColor Yellow
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
$body = @{
    client_id = $clientId
    code = $code
    code_verifier = $PKCE.Verifier
    redirect_uri = $redirectUri
    grant_type = "authorization_code"
    scope = "User.Read"
}

# Make the token request
$token = Invoke-RestMethod -Uri $tokenUrl -Method Post -Body $body

Write-Host "`nToken Response:" -ForegroundColor Cyan
$token | ConvertTo-Json

The Microsoft Graph SDK, to my complete surprise (I hadn’t taken any notice before) leaves the authorization code exposed in the address bar. queue impending disaster music

While the code is short-lived (10 minutes) and single-use, clearing it from the address bar is still considered good security practice in my opinion. Maybe i’m missing something and the SDK team know something I don’t.. shrugs

Pretty Redirect Pages

One thing you see a lot when using the Microsoft Graph SDK, is the lack of creativity in the redirect URL HTML. Im not pointing fingers. Keeping code light is always keys but you can get a little more adventurous then the SDK does. Its also nice to brand the page a little so your users know they didn’t just click on something thats gonna get them fired.

I’m no graphic designer but this looks great, right?

In our custom code, we can create some pretty messages, using colour schemes and base64 logos (perhaps). The function below sends a styled HTML response page to the browser after the authentication is complete. It takes a HttpListenerResponse parameter to handle the web response and creates a HTML page with a centred container, white background, “Authentication Complete” heading, and a message telling the user they can close the window. The page includes CSS styling for a modern, clean look and importantly.

Thinking back to PKCE, this function also use a JavaScript snippet that clears the auth code from the URL bar. Nice touch I thought…security through obscurity 😀

function Send-ResponseHtml {
    param ([System.Net.HttpListenerResponse]$Response)

    $html = @"
<!DOCTYPE html>
<html>
<head>
    <title>Authentication Complete</title>
    <style>
        body { font-family: "Segoe UI", sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f0f2f5; }
        .container { background-color: white; padding: 40px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
        h1 { margin-bottom: 16px; font-size: 28px; }
        p { font-size: 18px; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Authentication Complete</h1>
        <p>You can close this window now, Cheese Man.</p>
    </div>
    <script>window.onload = function() { history.replaceState({}, document.title, '/'); }</script>
</body>
</html>
"@

    $buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
    $Response.ContentType = "text/html"
    $Response.ContentLength64 = $buffer.Length
    $Response.StatusCode = 200
    $Response.StatusDescription = "OK"
    $Response.OutputStream.Write($buffer, 0, $buffer.Length)
    $Response.OutputStream.Flush()
    $Response.Close()
}

The Complete Implementation

Here’s our complete implementation. This PowerShell script performs OAuth 2.0 PKCE authentication to get an access token from Microsoft Entra ID. The token allows authenticated API requests, like calling Microsoft Graph.

Find an Available PortGet-RedirectUri

  • Chooses a free port, in the dynamic range, for the redirect URI (http://localhost:<port>).

Generate a PKCE CodeGenerate-PKCE

  • Creates a secure Code Verifier and Code Challenge to improve security.

Start AuthenticationGet-AuthorizationCode

  • Opens a browser for the user to sign in.
  • Waits for Entra ID to send back an authorization code.

Exchange Code for TokenExchange-AuthCodeForToken

  • Sends the authorization code and PKCE verifier to Entra ID.
  • Receives an access token (used for API requests and such).

Check Token ValidityGet-AccessToken

If no valid token exists, it starts the authentication flow again.

[CmdletBinding(SupportsShouldProcess)]
param (
    [string]$TenantId = '',
    [string]$ClientId = '',
    [string]$Scope = "User.Read",
    [int]$MinimumValiditySeconds = 300
)

function Get-AvailablePort {
    $tcpListener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0)
    try {
        $tcpListener.Start()
        $port = ([System.Net.IPEndPoint]$tcpListener.LocalEndpoint).Port
        return $port
    }
    finally {
        $tcpListener.Stop()
    }
}

function Get-RedirectUri {
    $port = Get-AvailablePort
    return "http://localhost:$port"
}

function Generate-PKCE {
    $codeVerifier = -join ((65..90) + (97..122) | Get-Random -Count 128 | % { [char]$_ })
    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($codeVerifier)
    $codeChallenge = [System.Convert]::ToBase64String($sha256.ComputeHash($bytes)) -replace '\+', '-' -replace '/', '_' -replace '='
    return @{ Verifier = $codeVerifier; Challenge = $codeChallenge }
}

function Get-AuthorizationCode {
    param (
        [string]$TenantId,
        [string]$ClientId,
        [string]$RedirectUri,
        [string]$CodeChallenge,
        [string]$Scope
    )

    $http = [System.Net.HttpListener]::new()
    $http.Prefixes.Add("$RedirectUri/")
    $http.Start()

    $state = -join ((65..90) + (97..122) | Get-Random -Count 32 | % {[char]$_})
    $encodedState = [System.Web.HttpUtility]::UrlEncode($state)

    $authUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/authorize" `
        + "?client_id=$ClientId" `
        + "&response_type=code" `
        + "&redirect_uri=$RedirectUri" `
        + "&scope=$Scope" `
        + "&code_challenge=$CodeChallenge" `
        + "&code_challenge_method=S256" `
        + "&state=$encodedState"

    Write-Host "Opening authentication page..."
    Start-Process $authUrl

    $context = $http.GetContext()
    $receivedState = $context.Request.QueryString["state"]
    if ($receivedState -ne $encodedState) {
        throw "State parameter mismatch"
    }

    $code = $context.Request.QueryString["code"]
    Send-ResponseHtml -Response $context.Response

    $http.Stop()
    return $code
}

function Send-ResponseHtml {
    param ([System.Net.HttpListenerResponse]$Response)

    $html = @"
<!DOCTYPE html>
<html>
<head>
    <title>Authentication Complete</title>
    <style>
        body { font-family: "Segoe UI", sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f0f2f5; }
        .container { background-color: white; padding: 40px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
        h1 { margin-bottom: 16px; font-size: 28px; }
        p { font-size: 18px; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Authentication Complete</h1>
        <p>You can close this window now, Cheese Man.</p>
    </div>
    <script>window.onload = function() { history.replaceState({}, document.title, '/'); }</script>
</body>
</html>
"@

    $buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
    $Response.ContentType = "text/html"
    $Response.ContentLength64 = $buffer.Length
    $Response.StatusCode = 200
    $Response.StatusDescription = "OK"
    $Response.OutputStream.Write($buffer, 0, $buffer.Length)
    $Response.OutputStream.Flush()
    $Response.Close()
}

function Exchange-AuthCodeForToken {
    param (
        [string]$TenantId,
        [string]$ClientId,
        [string]$RedirectUri,
        [string]$Code,
        [string]$CodeVerifier,
        [string]$Scope
    )

    Write-Verbose "Exchange-AuthCodeForToken called with:"
    Write-Verbose "Code: $Code"
    Write-Verbose "CodeVerifier: $CodeVerifier"
    Write-Verbose "RedirectUri: $RedirectUri"

    $tokenUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    $body = @{
        client_id     = $ClientId
        code          = $Code
        code_verifier = $CodeVerifier
        redirect_uri  = $RedirectUri
        grant_type    = "authorization_code"
        scope         = $Scope
    }

    Write-Verbose "Request body:"
    Write-Verbose ($body | ConvertTo-Json)

    # Add content type explicitly
    $token = Invoke-RestMethod -Uri $tokenUrl -Method Post -Body $body -ContentType "application/x-www-form-urlencoded"
    return @{
        AccessToken = $token.access_token
        ExpiresAt   = (Get-Date).AddSeconds($token.expires_in)
        TokenType   = $token.token_type
        Scope       = $token.scope
    }
}

function Get-AccessToken {
    param (
        [string]$TenantId,
        [string]$ClientId,
        [string]$Scope,
        [datetime]$TokenExpiry = [DateTime]::MinValue,
        [int]$MinimumValiditySeconds
    )

    Write-Verbose ("Current time is: {0}" -f (Get-Date))
    Write-Verbose ("Token expiry time is: {0}" -f $TokenExpiry)
    Write-Verbose ("Minimum validity seconds required: {0}" -f $MinimumValiditySeconds)

    $minimumValidUntil = (Get-Date).AddSeconds($MinimumValiditySeconds)
    Write-Verbose ("Token must be valid until: {0}" -f $minimumValidUntil)
    
    if ($TokenData -and $TokenExpiry -gt $minimumValidUntil) {
        $timeUntilExpiry = ($TokenExpiry - (Get-Date)).ToString("hh\:mm\:ss")
        Write-Verbose ("Using existing valid access token. Token expires in: {0}" -f $timeUntilExpiry)
        return $TokenData
    }

    Write-Verbose ("Token validation failed because expiry time {0} is not after minimum valid until time {1}" -f $TokenExpiry, $minimumValidUntil)
    Write-Verbose "Starting authentication for new token..."

    $RedirectUri = Get-RedirectUri
    $PKCE = Generate-PKCE
    $Code = Get-AuthorizationCode -TenantId $TenantId -ClientId $ClientId -RedirectUri $RedirectUri -CodeChallenge $PKCE.Challenge -Scope $Scope
    return Exchange-AuthCodeForToken -TenantId $TenantId -ClientId $ClientId -RedirectUri $RedirectUri -Code $Code -CodeVerifier $PKCE.Verifier -Scope $Scope
}

# Main execution flow
$minimumValidUntil = (Get-Date).AddSeconds($MinimumValiditySeconds)

Write-Verbose ("Current time is: {0}" -f (Get-Date))
Write-Verbose ("TokenData exists: {0}" -f ($null -ne $TokenData))
Write-Verbose "Checking if existing token is valid..."

if ($TokenData -and $TokenData.ExpiresAt -gt $minimumValidUntil) {
    $timeUntilExpiry = ($TokenData.ExpiresAt - (Get-Date)).ToString("hh\:mm\:ss")
    Write-Verbose ("Using existing valid access token. Token expires in: {0}" -f $timeUntilExpiry)
}
else {
    Write-Verbose "Token validation failed because:"
    if (-not $TokenData) {
        Write-Verbose "  - TokenData is null or empty"
    }
    elseif (-not ($TokenData.ExpiresAt -gt $minimumValidUntil)) {
        Write-Verbose ("  - Token expiry time ({0}) is not greater than minimum valid until time ({1})" -f $TokenData.ExpiresAt, $minimumValidUntil)
    }
    
    Write-Verbose "Getting new token..."
    $tokenExpiry = if ($TokenData) { $TokenData.ExpiresAt } else { [DateTime]::MinValue }
    
    $TokenData = Get-AccessToken -TenantId $TenantId -ClientId $ClientId -Scope $Scope `
        -TokenExpiry $tokenExpiry -MinimumValiditySeconds $MinimumValiditySeconds
}

Write-Verbose "`nAccess Token Details:"
Write-Verbose "Access Token: (Stored in memory as `$TokenData.AccessToken)"
Write-Verbose ("Expires At: {0}" -f $TokenData.ExpiresAt)
Write-Verbose ("Token Type: {0}" -f $TokenData.TokenType)
Write-Verbose ("Scope: {0}" -f $TokenData.Scope)

Why Use the SDK Instead?

Now that we understand how OAuth works, let’s talk about why the Microsoft Graph PowerShell SDK is often the better choice still.

Simplicity……

# Install and import the module if you haven't already
Install-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Authentication

# Connect and get an access token automatically
Connect-MgGraph -Scopes "DeviceManagementConfiguration.Read.All"

# Now you can use any Graph API endpoints
Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices'

The Microsoft Graph PowerShell SDK abstracts the complexity of authentication and API interactions. Unlike manually handling OAuth authentication in the example above, the SDK automatically refreshes tokens (in the right authentication flow scenarios), eliminating the need for re-authentication when an access token expires.

Secondly, the continuous updates of the managed SDK ensure alignment with the latest security practices, protecting against potential vulnerabilities that might emerge in custom implementations.

Conclusion

While you might be reaching the end of the blog thinking “man that wasted my time”, working through PKCE and OAuth step-by-step provides invaluable insights into the underlying security mechanisms. It’s an educational exercise that demystifies the intricate processes SDKs seamlessly manage, helping developers understand the sophisticated dance of modern authentication. Hopefully it also tilted the cap towards the idea of creating “prettier” redirect pages.

Final thought – keep that token secure! Keeping access tokens secure is critical to preventing unauthorized access to your application or the Microsoft Graph API. Consider using Credential Manager or DPAPI to store your token (not covered in this post). Absolutely do not store the token in global variables like ($global:TokenData). Finally, Never store plain text tokens in files or use them in logs.

Until next time, thanks for reading!

Ben Whitmore

Microsoft MVP - Enterprise Mobility, Microsoft Certified Trainer and Microsoft 365 Certified: Enterprise Administrator Expert. Community driven and passionate Customer Engineer Lead at Patch My PC with over 2 decades of experience in driving adoption and technology change within the Enterprise.

Add comment

Sponsors

Categories

MSEndpointMgr.com use cookies to ensure that we give you the best experience on our website.