Private/Invoke-OAuthBrowserFlow.ps1
|
function Invoke-OAuthBrowserFlow { <# .SYNOPSIS Performs OAuth Authorization Code flow with PKCE using browser redirect. .DESCRIPTION This internal function handles interactive OAuth authentication: 1. Generates PKCE code verifier and challenge 2. Starts a local HTTP listener to receive the callback 3. Opens the browser to the authorization endpoint 4. Captures the authorization code from the callback 5. Exchanges the code for tokens Security measures implemented per RFC 8252: - Binds to 127.0.0.1 loopback only (not 0.0.0.0) - Uses random ephemeral port if default is busy - Accepts exactly one request then shuts down - Validates state parameter for CSRF protection - Uses PKCE (code_challenge) to prevent authorization code interception .PARAMETER AuthorizeEndpoint The OAuth authorization endpoint URL. .PARAMETER TokenEndpoint The OAuth token endpoint URL. .PARAMETER ClientId The OAuth client ID. .PARAMETER Scopes Array of OAuth scopes to request. .PARAMETER RedirectPort The starting port to try for the callback. Defaults to 8400. Will try up to 10 ports if the specified port is in use. .PARAMETER TimeoutSeconds How long to wait for the user to complete authentication. Defaults to 300 (5 minutes). .OUTPUTS Returns a hashtable with AccessToken, RefreshToken, ExpiresAt, and TokenType. .NOTES This function implements: - PKCE (RFC 7636) as required by OAuth 2.1 for public clients - Loopback redirect security (RFC 8252 Section 7.3 and 8.3) #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$AuthorizeEndpoint, [Parameter(Mandatory)] [string]$TokenEndpoint, [Parameter(Mandatory)] [string]$ClientId, [Parameter(Mandatory)] [string[]]$Scopes, [int]$RedirectPort = 8400, [int]$TimeoutSeconds = 300 ) # Generate PKCE code verifier (43-128 characters, unreserved URI characters) $codeVerifier = New-PkceCodeVerifier $codeChallenge = Get-PkceCodeChallenge -CodeVerifier $codeVerifier Write-Verbose "Generated PKCE code verifier and challenge" # Use a fixed callback path for IDP compatibility # Security is provided by PKCE + state parameter, not by path obscurity # Entra ID and other IDPs require exact redirect URI matching $callbackPath = "/callback/" # Find an available port on 127.0.0.1 loopback only (RFC 8252 Section 7.3) $listener = $null $actualPort = $RedirectPort $maxPortAttempts = 10 for ($i = 0; $i -lt $maxPortAttempts; $i++) { try { $listener = [System.Net.HttpListener]::new() # Use localhost for IDP compatibility (Entra ID prefers http://localhost) # HttpListener on localhost still only accepts local connections $redirectUri = "http://localhost:$actualPort$callbackPath" $listener.Prefixes.Add($redirectUri) $listener.Start() Write-Verbose "Started HTTP listener on localhost:$actualPort with callback path $callbackPath" break } catch { if ($listener) { $listener.Close() $listener = $null } $actualPort++ if ($i -eq $maxPortAttempts - 1) { throw "Failed to start HTTP listener. Tried ports $RedirectPort to $actualPort. Ensure no other application is using these ports." } } } try { $redirectUri = "http://localhost:$actualPort$callbackPath" # Generate cryptographically random state for CSRF protection $stateBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) $state = [Convert]::ToBase64String($stateBytes).Replace('+', '-').Replace('/', '_').TrimEnd('=') # Build authorization URL $authParams = @{ client_id = $ClientId response_type = 'code' redirect_uri = $redirectUri scope = $Scopes -join ' ' state = $state code_challenge = $codeChallenge code_challenge_method = 'S256' } $queryString = ($authParams.GetEnumerator() | ForEach-Object { "$([Uri]::EscapeDataString($_.Key))=$([Uri]::EscapeDataString($_.Value))" }) -join '&' $authUrl = "$AuthorizeEndpoint`?$queryString" Write-Verbose "Authorization URL: $authUrl" # Open browser Write-Host "Opening browser for authentication..." -ForegroundColor Cyan Write-Host "If the browser doesn't open automatically, navigate to:" -ForegroundColor Yellow Write-Host $authUrl -ForegroundColor Gray Open-Browser -Url $authUrl # Wait for callback (single request only per RFC 8252) Write-Host "Waiting for authentication to complete..." -ForegroundColor Cyan $context = $null $asyncResult = $listener.BeginGetContext($null, $null) if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000)) { throw "Authentication timed out after $TimeoutSeconds seconds. Please try again." } $context = $listener.EndGetContext($asyncResult) # Parse the callback $request = $context.Request $response = $context.Response # Verify the request path matches our callback path (defence in depth) # Normalise both paths by trimming trailing slashes for comparison $expectedPath = $callbackPath.TrimEnd('/') $actualPath = $request.Url.AbsolutePath.TrimEnd('/') if ($actualPath -ne $expectedPath) { Send-CallbackResponse -Response $response -Success $false -Message "Authentication failed: Invalid callback path" throw "Received callback on unexpected path. This could indicate a security issue." } $queryParams = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query) # Check for errors if ($queryParams['error']) { $errorDescription = $queryParams['error_description'] ?? $queryParams['error'] Send-CallbackResponse -Response $response -Success $false -Message "Authentication failed: $errorDescription" throw "OAuth error: $errorDescription" } # Validate state (CSRF protection) if ($queryParams['state'] -ne $state) { Send-CallbackResponse -Response $response -Success $false -Message "Authentication failed: Invalid state parameter" throw "OAuth state mismatch. This could indicate a CSRF attack. Please try again." } # Get authorization code $authCode = $queryParams['code'] if (-not $authCode) { Send-CallbackResponse -Response $response -Success $false -Message "Authentication failed: No authorization code received" throw "No authorization code received from the identity provider." } # Send success response to browser Send-CallbackResponse -Response $response -Success $true -Message "Authentication successful! You can close this window." Write-Verbose "Received authorization code, exchanging for tokens..." # Exchange code for tokens $tokenParams = @{ client_id = $ClientId grant_type = 'authorization_code' code = $authCode redirect_uri = $redirectUri code_verifier = $codeVerifier } $tokenBody = ($tokenParams.GetEnumerator() | ForEach-Object { "$([Uri]::EscapeDataString($_.Key))=$([Uri]::EscapeDataString($_.Value))" }) -join '&' $tokenResponse = Invoke-RestMethod -Uri $TokenEndpoint -Method Post -Body $tokenBody -ContentType 'application/x-www-form-urlencoded' # Calculate expiry time $expiresAt = (Get-Date).AddSeconds($tokenResponse.expires_in - 60) # Subtract 60s buffer Write-Verbose "Successfully obtained access token" return @{ AccessToken = $tokenResponse.access_token RefreshToken = $tokenResponse.refresh_token ExpiresAt = $expiresAt TokenType = $tokenResponse.token_type ?? 'Bearer' Scopes = $Scopes } } finally { # SECURITY: Always shut down listener after single request (RFC 8252) if ($listener) { $listener.Stop() $listener.Close() Write-Verbose "HTTP listener stopped" } } } function New-PkceCodeVerifier { <# .SYNOPSIS Generates a cryptographically random PKCE code verifier. .DESCRIPTION Creates a 64-character code verifier using unreserved URI characters as specified in RFC 7636. .OUTPUTS A 64-character string suitable for use as a PKCE code verifier. #> [CmdletBinding()] [OutputType([string])] param() # Generate 48 random bytes (will produce 64 base64url characters) $bytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48) # Convert to base64url (RFC 4648) $base64 = [Convert]::ToBase64String($bytes) $base64Url = $base64.Replace('+', '-').Replace('/', '_').TrimEnd('=') return $base64Url } function Get-PkceCodeChallenge { <# .SYNOPSIS Generates a PKCE code challenge from a code verifier. .DESCRIPTION Creates a SHA256 hash of the code verifier and encodes it as base64url as specified in RFC 7636 for the S256 method. .PARAMETER CodeVerifier The PKCE code verifier to hash. .OUTPUTS The base64url-encoded SHA256 hash of the code verifier. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string]$CodeVerifier ) $bytes = [System.Text.Encoding]::ASCII.GetBytes($CodeVerifier) $hash = [System.Security.Cryptography.SHA256]::HashData($bytes) # Convert to base64url (RFC 4648) $base64 = [Convert]::ToBase64String($hash) $base64Url = $base64.Replace('+', '-').Replace('/', '_').TrimEnd('=') return $base64Url } function Open-Browser { <# .SYNOPSIS Opens the default browser to a specified URL. .DESCRIPTION Cross-platform function to open a URL in the default browser. Supports Windows, macOS, and Linux. .PARAMETER Url The URL to open. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Url ) try { if ($IsWindows -or $env:OS -match 'Windows') { # Windows Start-Process $Url } elseif ($IsMacOS) { # macOS Start-Process 'open' -ArgumentList $Url } else { # Linux - try common browsers/openers $browsers = @('xdg-open', 'sensible-browser', 'x-www-browser', 'gnome-open', 'firefox', 'chromium-browser', 'google-chrome') $opened = $false foreach ($browser in $browsers) { try { $null = Get-Command $browser -ErrorAction Stop Start-Process $browser -ArgumentList $Url $opened = $true break } catch { continue } } if (-not $opened) { Write-Warning "Could not open browser automatically. Please open the URL manually." } } } catch { Write-Warning "Failed to open browser: $_" Write-Warning "Please open the URL manually." } } function Send-CallbackResponse { <# .SYNOPSIS Sends an HTML response to the OAuth callback request. .DESCRIPTION Sends a simple HTML page to the browser indicating success or failure. .PARAMETER Response The HttpListenerResponse object. .PARAMETER Success Whether authentication was successful. .PARAMETER Message The message to display. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Net.HttpListenerResponse]$Response, [Parameter(Mandatory)] [bool]$Success, [Parameter(Mandatory)] [string]$Message ) # JIM brand colours (matches JIM.Web dark theme) $bgPage = '#0e1420' $bgCard = '#121826' $borderColour = '#2a303c' $accentColour = if ($Success) { '#7c4dff' } else { '#f44336' } $textColour = '#ffffffde' $subtitleColour = '#ffffff99' $icon = if ($Success) { '✓' } else { '✗' } $html = @" <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>JIM Authentication</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: $bgPage; } .container { text-align: center; padding: 40px 60px; background: $bgCard; border: 1px solid $borderColour; border-radius: 12px; max-width: 400px; box-shadow: 0 4px 24px rgba(124, 77, 255, 0.15); } .icon { font-size: 48px; color: $accentColour; margin-bottom: 16px; } .message { font-size: 18px; color: $textColour; margin-bottom: 8px; font-weight: 500; } .subtitle { font-size: 14px; color: $subtitleColour; } .logo { font-size: 24px; font-weight: 700; color: $accentColour; margin-bottom: 24px; letter-spacing: 2px; } </style> </head> <body> <div class="container"> <div class="logo">JIM</div> <div class="icon">$icon</div> <div class="message">$Message</div> <div class="subtitle">You can close this window and return to PowerShell.</div> </div> </body> </html> "@ $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) $Response.ContentLength64 = $buffer.Length $Response.ContentType = 'text/html; charset=utf-8' $Response.StatusCode = 200 $Response.OutputStream.Write($buffer, 0, $buffer.Length) $Response.OutputStream.Close() } function Invoke-OAuthTokenRefresh { <# .SYNOPSIS Refreshes an OAuth access token using a refresh token. .DESCRIPTION Uses the refresh token to obtain a new access token without requiring user interaction. .PARAMETER TokenEndpoint The OAuth token endpoint URL. .PARAMETER ClientId The OAuth client ID. .PARAMETER RefreshToken The refresh token to use. .PARAMETER Scopes Array of OAuth scopes to request. .OUTPUTS Returns a hashtable with AccessToken, RefreshToken, ExpiresAt, and TokenType. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$TokenEndpoint, [Parameter(Mandatory)] [string]$ClientId, [Parameter(Mandatory)] [string]$RefreshToken, [Parameter(Mandatory)] [string[]]$Scopes ) Write-Verbose "Refreshing access token..." $tokenParams = @{ client_id = $ClientId grant_type = 'refresh_token' refresh_token = $RefreshToken scope = $Scopes -join ' ' } $tokenBody = ($tokenParams.GetEnumerator() | ForEach-Object { "$([Uri]::EscapeDataString($_.Key))=$([Uri]::EscapeDataString($_.Value))" }) -join '&' try { $tokenResponse = Invoke-RestMethod -Uri $TokenEndpoint -Method Post -Body $tokenBody -ContentType 'application/x-www-form-urlencoded' # Calculate expiry time $expiresAt = (Get-Date).AddSeconds($tokenResponse.expires_in - 60) Write-Verbose "Successfully refreshed access token" return @{ AccessToken = $tokenResponse.access_token RefreshToken = $tokenResponse.refresh_token ?? $RefreshToken # Some providers don't return a new refresh token ExpiresAt = $expiresAt TokenType = $tokenResponse.token_type ?? 'Bearer' Scopes = $Scopes } } catch { Write-Verbose "Token refresh failed: $_" throw "Failed to refresh access token. You may need to re-authenticate using Connect-JIM." } } function Get-OidcDiscoveryDocument { <# .SYNOPSIS Fetches the OIDC discovery document from an authority. .DESCRIPTION Retrieves the OpenID Connect discovery document to obtain the authorization and token endpoints. .PARAMETER Authority The OIDC authority URL. .OUTPUTS Returns a hashtable with AuthorizeEndpoint and TokenEndpoint. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$Authority ) $discoveryUrl = "$($Authority.TrimEnd('/'))/.well-known/openid-configuration" Write-Verbose "Fetching OIDC discovery document from $discoveryUrl" try { $discovery = Invoke-RestMethod -Uri $discoveryUrl -Method Get return @{ AuthorizeEndpoint = $discovery.authorization_endpoint TokenEndpoint = $discovery.token_endpoint Issuer = $discovery.issuer } } catch { throw "Failed to fetch OIDC discovery document from $discoveryUrl`: $_" } } |