Private/Auth/Invoke-MgcInteractiveAuth.ps1

function Invoke-MgcInteractiveAuth {
    <#
    .SYNOPSIS
        OAuth 2.0 Authorization Code + PKCE via system browser and a loopback listener.

    .DESCRIPTION
        - Generates a PKCE pair.
        - Starts an HttpListener on an OS-assigned (or user-specified) loopback port.
        - Opens the user's default browser to the /authorize endpoint.
        - Waits (async with 5-min timeout) for the redirect callback.
        - Validates the state parameter to defend against CSRF.
        - Exchanges the authorization code + verifier for tokens at /token.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost','',
        Justification = 'Interactive flow — user-visible progress.')]
    param(
        [Parameter(Mandatory)][string]$LoginEndpoint,
        [Parameter(Mandatory)][string]$TenantSegment,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string[]]$Scopes,
        [int]    $RedirectPort,
        [switch] $ForceConsent
    )

    if (-not $RedirectPort -or $RedirectPort -le 0) { $RedirectPort = Get-MgcFreePort }
    $redirectUri = "http://localhost:$RedirectPort/"

    $pkce  = New-MgcPkcePair
    $state = [Guid]::NewGuid().ToString('N')

    $authParams = [ordered]@{
        client_id             = $ClientId
        response_type         = 'code'
        redirect_uri          = $redirectUri
        response_mode         = 'query'
        scope                 = ($Scopes -join ' ')
        state                 = $state
        code_challenge        = $pkce.Challenge
        code_challenge_method = $pkce.Method
        prompt                = $(if ($ForceConsent) { 'consent' } else { 'select_account' })
    }

    $query   = ($authParams.GetEnumerator() | ForEach-Object {
        "$($_.Key)=$([Uri]::EscapeDataString([string]$_.Value))"
    }) -join '&'
    $authUrl = "$LoginEndpoint/$TenantSegment/oauth2/v2.0/authorize?$query"

    $listener = [System.Net.HttpListener]::new()
    $listener.Prefixes.Add($redirectUri)
    $listener.Start()

    try {
        Write-Host "Opening browser for sign-in (listening on $redirectUri)..." -ForegroundColor Cyan
        Start-Process $authUrl | Out-Null

        $ctxTask = $listener.GetContextAsync()
        if (-not $ctxTask.Wait([TimeSpan]::FromMinutes(5))) {
            throw "Authentication timed out after 5 minutes."
        }
        $ctx = $ctxTask.Result

        $code          = $ctx.Request.QueryString['code']
        $err           = $ctx.Request.QueryString['error']
        $errDesc       = $ctx.Request.QueryString['error_description']
        $returnedState = $ctx.Request.QueryString['state']

        $successHtml = "<html><body style='font-family:Segoe UI,sans-serif;text-align:center;padding-top:80px;'><h2>Authentication Successful</h2><p>You can close this window and return to PowerShell.</p></body></html>"
        $errorHtml   = "<html><body style='font-family:Segoe UI,sans-serif;text-align:center;padding-top:80px;color:#c0392b;'><h2>Authentication Failed</h2><p>$([System.Net.WebUtility]::HtmlEncode($errDesc))</p></body></html>"

        $html  = if ($code) { $successHtml } else { $errorHtml }
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($html)
        $ctx.Response.ContentType    = 'text/html'
        $ctx.Response.ContentLength64 = $bytes.Length
        $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
        $ctx.Response.Close()

        if ($returnedState -ne $state) { throw "OAuth state mismatch - possible CSRF. Aborting." }
        if (-not $code) { throw "Authorization failed: $err - $errDesc" }

        $body = @{
            client_id     = $ClientId
            grant_type    = 'authorization_code'
            code          = $code
            redirect_uri  = $redirectUri
            code_verifier = $pkce.Verifier
            scope         = ($Scopes -join ' ')
        }
        return Invoke-MgcTokenEndpoint -Url "$LoginEndpoint/$TenantSegment/oauth2/v2.0/token" -Body $body
    }
    finally {
        $listener.Stop()
        $listener.Close()
    }
}