Private/Invoke-InteractiveAuth.ps1

function Invoke-InteractiveAuth {
    <#
    .SYNOPSIS
        Performs Authorization Code + PKCE interactive authentication via a localhost
        HTTP listener and the user's default browser.
 
    .DESCRIPTION
        1. Generates a PKCE code verifier and challenge.
        2. Starts a temporary HttpListener on a random high port.
        3. Opens the browser to the /authorize endpoint with prompt=select_account.
        4. Captures the redirect, validates the state parameter (CSRF protection),
           and extracts the authorization code.
        5. Exchanges the code + verifier for tokens via Invoke-TokenRequest.
 
        PKCE is mandatory — mitigates authorization code interception attacks.
        The state parameter is validated to prevent CSRF.
        Token values are never logged.
 
    .PARAMETER TenantId
        Azure AD / Entra ID tenant ID (GUID or domain).
 
    .PARAMETER ClientId
        Application (client) ID.
 
    .PARAMETER Scopes
        Space-separated scopes to request.
 
    .NOTES
        Author: Nickolaj Andersen & Jan Ketil Skanke
        Contact: @NickolajA @JankeSkanke
        Created: 2026-02-19
 
        Version history:
        1.0.0 - (2026-02-19) Script created
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$ClientId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Scopes
    )
    Process {
        $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"

        # 1. Generate PKCE code verifier & challenge
        $codeVerifierBytes = [byte[]]::new(32)
        $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
        $rng.GetBytes($codeVerifierBytes)
        $rng.Dispose()
        $codeVerifier = [Convert]::ToBase64String($codeVerifierBytes) -replace '\+', '-' -replace '/', '_' -replace '='

        $sha256 = [System.Security.Cryptography.SHA256]::Create()
        $challengeHash = $sha256.ComputeHash(
            [System.Text.Encoding]::ASCII.GetBytes($codeVerifier)
        )
        $sha256.Dispose()
        $codeChallenge = [Convert]::ToBase64String($challengeHash) -replace '\+', '-' -replace '/', '_' -replace '='

        # 2. Pick a random localhost port and set up a temporary HTTP listener
        $port = Get-Random -Minimum 49152 -Maximum 65535
        $redirectUri = "http://localhost:$port/"
        $listener = [System.Net.HttpListener]::new()
        $listener.Prefixes.Add($redirectUri)
        $listener.Start()

        # 3. Build and open the authorize URL (prompt=select_account forces account picker)
        $state = [guid]::NewGuid().ToString('N')
        $authUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/authorize?" + (
            @(
                "client_id=$ClientId"
                "response_type=code"
                "redirect_uri=$([uri]::EscapeDataString($redirectUri))"
                "response_mode=query"
                "scope=$([uri]::EscapeDataString($Scopes))"
                "state=$state"
                "code_challenge=$codeChallenge"
                "code_challenge_method=S256"
                "prompt=select_account"
            ) -join '&'
        )

        Write-Host "[MSGraphRequest] Opening browser for sign-in..." -ForegroundColor Yellow
        Start-Process $authUrl

        # 4. Wait for the redirect (browser posts back)
        try {
            $httpContext = $listener.GetContext()  # blocks until browser redirects
            $query = $httpContext.Request.QueryString

            # Return a friendly page to the user
            $html = '<html><body><h3>Authentication complete — you can close this tab.</h3></body></html>'
            $buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
            $httpContext.Response.ContentLength64 = $buffer.Length
            $httpContext.Response.OutputStream.Write($buffer, 0, $buffer.Length)
            $httpContext.Response.OutputStream.Close()

            # Validate state to prevent CSRF
            if ($query['state'] -ne $state) {
                throw "State mismatch — possible CSRF attack. Aborting authentication."
            }
            if ($query['error']) {
                throw "Authorization error: $($query['error']) — $($query['error_description'])"
            }
            $authCode = $query['code']
        }
        finally {
            $listener.Stop()
            $listener.Close()
        }

        # 5. Exchange auth code + verifier for tokens
        $tokenBody = @{
            client_id     = $ClientId
            scope         = $Scopes
            code          = $authCode
            redirect_uri  = $redirectUri
            grant_type    = 'authorization_code'
            code_verifier = $codeVerifier
        }

        $response = Invoke-TokenRequest -TokenEndpoint $tokenEndpoint -Body $tokenBody
        Write-Verbose -Message "Interactive authentication completed successfully."

        return $response
    }
}