Private/Auth/Invoke-MgcInteractiveAuth.ps1
|
function Invoke-MgcInteractiveAuth { <# .SYNOPSIS OAuth 2.0 Authorization Code + PKCE via system browser and a loopback listener. .DESCRIPTION - Starts an HttpListener on an OS-assigned (or user-specified) loopback port, retrying with a fresh port if another process wins the bind race. - Generates a PKCE pair and a crypto-random state value. - Opens the user's default browser to the /authorize endpoint. - Waits (async with 5-min timeout) for the redirect callback; stray local requests (favicon.ico, preconnects) are answered with 404 and ignored. - 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 ) # Bind the listener BEFORE building the authorize URL. An OS-assigned port can # be grabbed by another process between probing it and binding the listener, # so retry with a fresh port. A user-specified port gets one attempt and a # clear error (the app registration may require that exact redirect URI). $autoPort = (-not $RedirectPort -or $RedirectPort -le 0) $maxBindAttempts = if ($autoPort) { 5 } else { 1 } $listener = $null for ($bindAttempt = 1; $bindAttempt -le $maxBindAttempts; $bindAttempt++) { if ($autoPort) { $RedirectPort = Get-MgcFreePort } $candidate = [System.Net.HttpListener]::new() $candidate.Prefixes.Add("http://localhost:$RedirectPort/") try { $candidate.Start() $listener = $candidate break } catch { $candidate.Close() if ($bindAttempt -eq $maxBindAttempts) { throw "Could not start the loopback listener on http://localhost:$RedirectPort/ : $($_.Exception.Message)" } } } $redirectUri = "http://localhost:$RedirectPort/" try { $pkce = New-MgcPkcePair # CSRF state from the same crypto RNG as the PKCE verifier (a GUID is not # contractually a CSPRNG). $stateBytes = [byte[]]::new(16) $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() try { $rng.GetBytes($stateBytes) } finally { $rng.Dispose() } $state = [Convert]::ToBase64String($stateBytes).TrimEnd('=').Replace('+','-').Replace('/','_') $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" Write-Host "Opening browser for sign-in (listening on $redirectUri)..." -ForegroundColor Cyan Start-Process $authUrl | Out-Null # Wait for the OAuth callback within an overall 5-minute deadline. # Stray local requests (favicon.ico, browser preconnects) get a 404 and # do not consume the flow. $deadline = [DateTime]::UtcNow.AddMinutes(5) $ctx = $null while (-not $ctx) { $remaining = $deadline - [DateTime]::UtcNow if ($remaining -le [TimeSpan]::Zero) { throw "Authentication timed out after 5 minutes." } $ctxTask = $listener.GetContextAsync() if (-not $ctxTask.Wait($remaining)) { throw "Authentication timed out after 5 minutes." } $candidateCtx = $ctxTask.Result if ($candidateCtx.Request.QueryString['code'] -or $candidateCtx.Request.QueryString['error']) { $ctx = $candidateCtx } else { $candidateCtx.Response.StatusCode = 404 $candidateCtx.Response.Close() } } $code = $ctx.Request.QueryString['code'] $err = $ctx.Request.QueryString['error'] $errDesc = $ctx.Request.QueryString['error_description'] $returnedState = $ctx.Request.QueryString['state'] $stateValid = ($returnedState -eq $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 -and $stateValid) { $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 (-not $stateValid) { 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() } } |